Skip to content

Web API

Stevie Chryn edited this page Aug 24, 2026 · 1 revision

Web API

The web_api module exposes a lightweight HTTP/JSON interface over the emulated EtherNet/IP device. It lets you read and write tags, inspect datatype groupings, and perform bulk writes, all over plain HTTP, without an EtherNet/IP client. It is built on web.py and served via a background wsgiref WSGI server thread, so it can run alongside the EtherNet/IP server without blocking it.

from ethernetip_emulator.server.web_api import WebApi

How It Works

WebApi wraps a web.application with a fixed set of routes and binds them to the same tag registry, actions, and device state used by the EtherNet/IP side of the emulator. Every route ultimately calls back into actions._lookup / actions._read_attr / actions._write_attr, so a write made through the Web API fires the exact same on_change listeners as a write made by the PLC.

The server itself runs in a daemon thread (start() / stop()), and WebApi can be used as a context manager:

with WebApi("0.0.0.0", 8080) as api:
    ...  # server is running
# server is stopped automatically on exit

In practice you will not construct WebApi directly. AttributeDevice owns a single instance and manages its lifecycle for you, see The Device Module for build_web / start_web / shutdown.

Routes

Path Handler Methods Description
/health health GET Liveness check.
/tags tags GET List every tag with its current value, group, type, and read-only flag.
/tag/(.+) tag GET, PUT, POST Read or write a single tag by name.
/datatypes/? datatypes GET List all datatype groups and the tags in each.
/datatype/(.+) datatype GET List the tags belonging to a single datatype group.
/bulk/? bulk POST, PUT Write multiple tags in a single request, optionally atomically.

All responses are JSON with Content-Type: application/json, produced by the @endpoint decorator (see Error Handling below).

GET /health

Returns a simple status payload. Useful for readiness/liveness probes.

{"status": "ok"}

GET /tags

Returns every registered tag, serialized with its current value, datatype group, parser type name, and whether it is protected (read-only).

{
  "tags": [
    {
      "name": "I_LotNumber.DATA",
      "value": "LOT001",
      "group": "STRING",
      "type": "SSTRING",
      "readonly": false
    },
    {
      "name": "I_LotNumber.LEN",
      "value": "LOT001",
      "group": "STRING",
      "type": "DINT",
      "readonly": true
    }
  ]
}

GET /tag/<name>

Returns the serialized state of a single tag.

GET /tag/I_LotNumber.DATA
{
    "name": "I_LotNumber.DATA", 
    "value": "LOT001", 
    "group": "STRING", 
    "type": "SSTRING", 
    "readonly": false
}

Raises TagNotFoundError (404) if the tag does not exist.

PUT /tag/<name> (alias: POST)

Writes a new value to a tag. The request body must be JSON:

{"value": "LOT002"}
  • A scalar value is written to slice(0, 1).
  • A list value is written element-by-element, starting at index 0.
  • The tag's own type_validator is applied to each value before writing, so invalid values raise TagTypeError (400) rather than corrupting the tag.

Returns the tag's new serialized state on success. Raises TagReadOnlyError (403) if the tag is protected via AttributeDevice.protect.

GET /datatypes/

Groups all tags by their datatype (e.g. bool, uintarray, string) and lists the tags in each group, together with their parser type name.

{
  "datatypes": [
      {
          "name": "BOOL", 
          "tags": {
              "I_ProcessUnit": "BOOL", 
              "I_ClearLine": "BOOL"
          }
      },
      {
          "name": "UINTARRAY",
          "tags": {
              "O_Passed": "UINT"
          }
      }
  ]
}

Groups are ordered by the datatypes known to actions.type, followed by any additional groups found in the tag registry. Empty groups are omitted by default.

GET /datatype/<name>

Returns the tags belonging to a single named datatype group.

GET /datatype/bool
{
    "name": "bool", 
    "tags": {
        "I_ProcessUnit": "BOOL",
        "I_ClearLine": "BOOL"
    }
}

Raises DatatypeNotFoundError (404) if the name matches neither a group with tags nor a known datatype.

POST /bulk (alias: PUT)

Writes several tags in one request. Request body:

{
  "tags": {
    "I_LotNumber.DATA": "LOT002",
    "O_Step": [true, false, false]
  },
  "atomic": true
}
Field Type Default Description
tags object ... Mapping of tag name to the value(s) to write. Required.
atomic bool false If true, every value is validated (and the tags checked for read-only) before any write happens. If any tag fails validation, no writes occur.

Without atomic, tags are validated and written one at a time; a failure on one tag does not prevent the others from being written. Response shape:

{
    "tags": {
        "I_LotNumber.DATA": {
            "name": "I_LotNumber.DATA", 
            "value": "LOT002", 
            "group": "STRING", 
            "type": "SSTRING", 
            "readonly": false
        }
    },
    "errors": {
        "O_Step": {
            "error": "tag_read_only", 
            "message": "tag 'O_Step' is read-only"
        }
    }
}

The top-level errors key is only present if at least one tag failed.

Error Handling

Every route is wrapped with the endpoint decorator, which catches exceptions raised inside the handler and turns them into a JSON error body with the appropriate HTTP status:

Exception HTTP status error code
TagNotFoundError 404 Not Found tag_not_found
TagReadOnlyError 403 Forbidden tag_read_only
TagTypeError 400 Bad Request type_mismatch
DatatypeNotFoundError 404 Not Found datatype_not_found
BadRequestError 400 Bad Request bad_request
TypeError (uncaught) 403 Forbidden type_error
any other exception 500 Internal Server Error internal_error

All of these derive from ApiError, which supplies the status string and code, and formats itself as {"error": code, "message": "..."}.

{"error": "tag_not_found", "message": "unknown tag 'DOES_NOT_EXIST'"}

Unhandled, non-ApiError exceptions are logged with a full traceback (log.exception) before being reported as a generic internal_error.

Integration with AttributeDevice

AttributeDevice owns the single WebApi instance used by the running server, exposed as the class attribute AttributeDevice._web_api.

AttributeDevice.build_web(host, port)

Constructs the WebApi instance (but does not start it):

AttributeDevice.build_web("0.0.0.0", 8080)

AttributeDevice.start_web()

Starts the web server thread if one was built with build_web. Binding failures are handled gracefully rather than crashing the whole device:

  • PermissionError — logs a warning about binding to a privileged port (fix: run with elevated privileges or choose a port above 1023) and clears _web_api.
  • OSError — logs a warning that the port is already in use and clears _web_api.
AttributeDevice.build_web("0.0.0.0", 8080)
AttributeDevice.start_web()

AttributeDevice.shutdown()

Now also stops the Web API server as part of normal shutdown, before signalling the EtherNet/IP server to stop and resetting tag defaults:

AttributeDevice.shutdown()
# 1. WebApi.stop() if a web server is running
# 2. server_control["done"] = True, or SIGINT if no control dict is set
# 3. AttributeDevice.reset_defaults()

Typical startup sequence

# __main__.py
from ethernetip_emulator.server.device import AttributeDevice, apidict, device_controller
from ethernetip_emulator.server.tag_specs import tag_registry
import tags
import actions

if __name__ == "__main__":
    server_control = apidict(timeout=1.0)
    AttributeDevice.set_server_control(server_control)

    AttributeDevice.build_web("0.0.0.0", 8080)
    AttributeDevice.start_web()

    with AttributeDevice._actions.bind(AttributeDevice):
        device_controller(
            argv=tag_registry.build_argv(base_args=["--print"]),
            attribute_class=AttributeDevice,
            server={"control": server_control},
        )

Because start_web fails soft, a bad host/port configuration will disable the Web API for that run (with a warning) rather than preventing the EtherNet/IP server from starting.

Dependencies

The module requires the third-party web.py package (import web) in addition to the standard library wsgiref server used to actually bind and serve HTTP requests.

Clone this wiki locally