Skip to content

The Device Module

Stevie Chryn edited this page Jul 29, 2026 · 1 revision

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, actions

The 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._actions

Starting the Server

The 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},
        )

Step-by-step breakdown

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.

Shutting Down the Server

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.

Class API Reference

AttributeDevice.set_server_control(control)

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))

AttributeDevice.shutdown()

Signals the server to stop. Calls reset_defaults() after sending the stop signal.

AttributeDevice.shutdown()

AttributeDevice.reset_defaults()

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()

AttributeDevice._actions

The shared AttributeActions instance. All datatype helpers and on_change registrations operate on this object. Exposed at module level as actions for convenience.

How Tag Defaults Are Applied

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.

How Tag Writes Are Intercepted

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 listeners

on_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.

Clone this wiki locally