Repository navigation
The Device Module
The device module is the bridge between the EtherNet/IP server layer and your tag definitions and action handlers. It provides AttributeDevice (a subclass of cpppo's device.Attribute) which intercepts tag writes to fire on_change listeners, applies startup defaults, and manages the server lifecycle.
from ethernetip_emulator.server.device import AttributeDevice, actionsThe module-level actions object is a direct reference to AttributeDevice._actions, so both of these are equivalent:
from ethernetip_emulator.server.device import actions
# same as
actions = AttributeDevice._actionsThe typical entrypoint pattern uses three components together:
| Component | Role |
|---|---|
AttributeDevice |
Custom attribute class that hooks into tag writes |
apidict |
A shared control dictionary used to signal the server to stop |
device_controller |
The cpppo main loop that runs the EtherNet/IP server |
from ethernetip_emulator.server.device import (
AttributeDevice,
apidict,
device_controller,
)
from ethernetip_emulator.server.tag_registry import tag_registry
if __name__ == "__main__":
server_control = apidict(timeout=1.0)
AttributeDevice.set_server_control(server_control)
with AttributeDevice._actions.bind(AttributeDevice):
device_controller(
argv=tag_registry.build_argv(base_args=["--print"]),
attribute_class=AttributeDevice,
server={"control": server_control},
)1. Create the server control dict
server_control = apidict(timeout=1.0)apidict is a thread-safe dictionary used internally by cpppo to coordinate server state. The timeout value controls how frequently the server loop checks the control dict for a shutdown signal.
2. Register the control dict with AttributeDevice
AttributeDevice.set_server_control(server_control)This stores a reference to the control dict on the class so that AttributeDevice.shutdown() can signal the server to stop from anywhere in your code, including inside on_change handlers.
3. Bind actions and start the server
with AttributeDevice._actions.bind(AttributeDevice):
device_controller(
argv=tag_registry.build_argv(base_args=["--print"]),
attribute_class=AttributeDevice,
server={"control": server_control},
)The with block calls actions.__enter__(), which activates all registered on_change listeners and any background workers (e.g. increment.start). When the server exits (normally or via shutdown()) actions.__exit__() is called automatically, stopping all threads and clearing listeners.
device_controller argument |
Description |
|---|---|
argv |
The list of tag definitions. tag_registry.build_argv(base_args=["--print"]) builds this from all registered tags; --print enables live logging of tag writes to stdout. |
attribute_class |
Tells cpppo to use AttributeDevice instead of the default Attribute class, enabling write interception. |
server |
Passes the control dict to the server loop so it can be stopped gracefully. |
Call AttributeDevice.shutdown() from anywhere — including inside an on_change handler — to stop the server gracefully:
@actions.string.on_change("I_Command")
def handle_command(attr, key, value) -> None:
if value[0] == "Shutdown":
AttributeDevice.shutdown()shutdown() sets control["done"] = True on the apidict, which signals the cpppo server loop to exit cleanly on its next tick. If no server control dict has been registered (e.g. in a test context), it falls back to sending SIGINT to the current process. Either way, reset_defaults() is called afterwards to clear the tag registry and default values.
Registers the apidict instance that will be used by shutdown(). Must be called before the server starts.
AttributeDevice.set_server_control(apidict(timeout=1.0))Signals the server to stop. Calls reset_defaults() after sending the stop signal.
AttributeDevice.shutdown()Clears the in-memory default value cache and the tag registry. Called automatically by shutdown(), but can also be called manually between test runs to reset state.
AttributeDevice.reset_defaults()The shared AttributeActions instance. All datatype helpers and on_change registrations operate on this object. Exposed at module level as actions for convenience.
When AttributeDevice is instantiated for each tag (done internally by device_controller), it calls _ensure_defaults() to load the default values from tag_registry.build(). For each tag, the default value defined in your @tag_registry.register function is applied before the tag is handed to cpppo.
This means the value your PLC client reads on first connection is the default you specified:
@tag_registry.register
def _outputTags():
return [
("O_SerialNumber", actions.type.UINT(42)), # starts at 42, not 0
]Array defaults are merged element-by-element into any type-level defaults provided by cpppo, so only the indices you specify are overridden.
AttributeDevice overrides __setitem__, the method cpppo calls whenever a PLC client writes a tag value:
def __setitem__(self, key, value):
super().__setitem__(key, value) # commit the write to cpppo
self._actions.on_set(self, key, value) # fire listenerson_set resolves the tag name, looks up any registered on_change listeners for that name, and calls them. This is what makes @actions.<datatype>.on_change(...) work. There is no polling or background scan; every callback is driven directly by a PLC write.