Tutorial — From zero to live values
By the end you’ll have the adapter polling an EtherNet/IP simulator and publishing value changes onto MQTT, and you’ll have read, written, and controlled a signal from a client. Then you’ll see the same adapter consume a class-1 implicit I/O (push) stream. No hardware required.
Command examples use ec-uns-cmd, installed on PATH.
--body is a native JSON argument object; the tool constructs protobuf, subscribes before publishing,
and prints the reply result or error within a deadline. Topic instance addressing selects the device.
1. Prerequisites
Section titled “1. Prerequisites”- A Rust toolchain (stable) and Docker.
- A local MQTT broker on
localhost:1883(docker run -d -p 1883:1883 emqx/emqx). - The repo cloned, with the
edgecommonslibrary available (the workspace.cargo/config.tomlpath override points theedgecommonsdep at a sibling checkout for local development).
Two config sources drive the run: a messaging config (--transport MQTT <file>, the broker to
publish on) and the component config (-c FILE <file>, the device map). Both live under
crates/ethernet-ip-adapter/test-configs/.
2. Run the adapter against the in-process simulator
Section titled “2. Run the adapter against the in-process simulator”The default config.json uses the built-in sim backend (adapter: "sim") — one device, filler-plc,
with a fast and a slow poll group. No external simulator is needed. From the workspace root:
cargo run -p ethernet-ip-adapter -- \ --platform HOST --transport MQTT ./crates/ethernet-ip-adapter/test-configs/standalone-messaging.json \ -c FILE ./crates/ethernet-ip-adapter/test-configs/config.json \ -t my-thingYou should see it connect, define its metric families, and start polling. -t my-thing is the device
(Thing) name — the {device} token of every UNS topic.
3. Watch values flow
Section titled “3. Watch values flow”Subscribe to the UNS data class (any MQTT client) — this filter covers instance-scope adapter data; component-scope fleet data also requires ecv1/+/+/data/#:
Normal messaging carries protobuf bytes. In this organization workspace install the matching
Python decoder with pip install paho-mqtt -e ../core/libs/python, then display a human-readable
JSON projection with this subscriber. The here-document uses Bash; in PowerShell save its Python
contents to a .py file and run python <file>.py.
python - <<'PY'import jsonimport paho.mqtt.client as mqttfrom edgecommons.messaging.message import Message
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)c.on_connect = lambda c, u, f, rc, p: c.subscribe("ecv1/my-thing/ethernet-ip-adapter/+/data/#", qos=1)def on_message(c, u, m): message = Message.from_bytes(m.payload) print(m.topic, json.dumps(message.to_diagnostic_json(), indent=2))c.on_message = on_messagec.connect("localhost", 1883)try: c.loop_forever()except KeyboardInterrupt: passfinally: c.unsubscribe("ecv1/my-thing/ethernet-ip-adapter/+/data/#") c.disconnect()PYYou’ll see SouthboundSignalUpdate messages on
ecv1/my-thing/ethernet-ip-adapter/filler-plc/data/{signal} for the changing signals (line-speed,
fill-temp, tank-level, product-count, zone-temps, …), each with a value, a normalized
quality, the CIP address ({tagPath, type}), and the top-level identity. Also try
ecv1/+/+/state for the keepalive and ecv1/+/+/metric/# for southbound_health plus the
EtherNetIpConnection, EtherNetIpInventory, EtherNetIpPoll, EtherNetIpPublish, and
EtherNetIpCommand operational metric families.
4. Read a signal on demand
Section titled “4. Read a signal on demand”Read/write/control go through the library command inbox
(ecv1/{device}/ethernet-ip-adapter/cmd/{verb}): set header.name to the verb and reply_to to a
topic you subscribe. With an EdgeCommons client this is one request() call; the CLI below handles it:
ec-uns-cmd --broker localhost:1883 --device my-thing --component ethernet-ip-adapter --instance filler-plc sb/read --body '{"signals":[{"name":"tank-level"}]}'tank-level has scale: 0.1, so a raw 125 reads back 12.5.
5. Write a signal
Section titled “5. Write a signal”FILL_SETPOINT and MOTOR_RUN are the two entries in the device’s writes.allow list — everything
else is refused before any device I/O:
ec-uns-cmd --broker localhost:1883 --device my-thing --component ethernet-ip-adapter --instance filler-plc sb/write --body '{"writes":[{"name":"fill-setpoint","value":42.5}]}'Read it back to confirm. Each write also emits an evt/info/write-audit (or evt/warning/write-audit
on failure) audit event on the evt class.
6. Pause and resume the instance
Section titled “6. Pause and resume the instance”Pause stops polling/publishing for one device while keeping its connection truthful with a slow liveness
probe — useful during maintenance so you don’t get a wall of BAD samples:
ec-uns-cmd --broker localhost:1883 --device my-thing --component ethernet-ip-adapter --instance filler-plc sb/pauseec-uns-cmd --broker localhost:1883 --device my-thing --component ethernet-ip-adapter --instance filler-plc sb/resume7. Poll a real EtherNet/IP simulator (cpppo)
Section titled “7. Poll a real EtherNet/IP simulator (cpppo)”To poll a real CIP endpoint instead of the in-process sim, use config-cpppo.json (adapter: "ethernet-ip"), which points its endpoint at a cpppo tag
server. The bundled compose file brings up cpppo with the same tag layout:
docker compose up -d emqx enip-sim # broker + cpppo tag server on :44818cargo run -p ethernet-ip-adapter -- \ --platform HOST --transport MQTT ./crates/ethernet-ip-adapter/test-configs/standalone-messaging.json \ -c FILE ./crates/ethernet-ip-adapter/test-configs/config-cpppo.json -t my-thingNow the line-speed/tank-level/zone-temps samples come off the wire, decoded from real CIP replies.
Reads and writes are all cpppo serves: it has no Logix tag-list service, so sb/browse against it
answers with an error rather than an inventory. Tag discovery is a Logix-family capability, not
something every CIP device offers — see Browse a device’s
tags.
8. Consume a class-1 I/O (push) stream (OpENer)
Section titled “8. Consume a class-1 I/O (push) stream (OpENer)”Push mode is the other half of the adapter. config-push.json (mode: "push") consumes a class-1
implicit-I/O assembly the device produces at the RPI and maps its byte-offset fields to signals. The
compose file builds an OpENer sample I/O adapter serving the
demo assemblies (input 100, output 150, config 151):
docker compose up -d emqx enip-io-simcargo run -p ethernet-ip-adapter -- \ --platform HOST --transport MQTT ./crates/ethernet-ip-adapter/test-configs/standalone-messaging.json \ -c FILE ./crates/ethernet-ip-adapter/test-configs/config-opener.json -t my-thingThe adapter opens the class-1 connection (ForwardOpen), consumes the cyclic T→O frames, decodes the
configured input fields, and publishes them as SouthboundSignalUpdate on the same data class. An
sb/write to an allow-listed output field is staged into the O→T buffer and rides the next cyclic
frame (applied: "next-frame").
Class-1 I/O is a two-way UDP flow: the adapter sends to the device’s port
2222and the device sends back to an ephemeral port the adapter binds and advertises in the ForwardOpen. The return path therefore needs a route with no NAT in it. On a Linux Docker host keep the sim and the adapter on the same compose network. On Docker Desktop for Windows the WSL2 UDP NAT breaks that return path — run the class-1 leg on a Linux host or natively.
Next: the how-to guides for building your own device map, allow-listing writes, and deploying; the reference for every option, verb, metric, and type; the explanation for the poll-vs-push model.