Skip to content

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.

  • 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 edgecommons library available (the workspace .cargo/config.toml path override points the edgecommons dep 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:

Terminal window
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-thing

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

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.

Terminal window
python - <<'PY'
import json
import paho.mqtt.client as mqtt
from 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_message
c.connect("localhost", 1883)
try:
c.loop_forever()
except KeyboardInterrupt:
pass
finally:
c.unsubscribe("ecv1/my-thing/ethernet-ip-adapter/+/data/#")
c.disconnect()
PY

You’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.

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:

Terminal window
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.

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:

Terminal window
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.

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:

Terminal window
ec-uns-cmd --broker localhost:1883 --device my-thing --component ethernet-ip-adapter --instance filler-plc sb/pause
ec-uns-cmd --broker localhost:1883 --device my-thing --component ethernet-ip-adapter --instance filler-plc sb/resume

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

Terminal window
docker compose up -d emqx enip-sim # broker + cpppo tag server on :44818
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-cpppo.json -t my-thing

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

Terminal window
docker compose up -d emqx enip-io-sim
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-opener.json -t my-thing

The 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 2222 and 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.