tedge-dot
OT protocol connectors for thin-edge.io: one tedge-dot
binary that moves data between industrial (OT) protocols and the thin-edge.io
MQTT broker.
Status: alpha. The MQTT contract, config format and packaging may still change between releases.
AI-driven project. tedge-dot is developed largely by AI coding agents. Check what it does before you rely on it, and report what you find.
Two implementations, one contract
Section titled “Two implementations, one contract”tedge-dot exists twice, as two maintained implementations of the same
OT Connector Contract, released together as two interchangeable
packages that both install /usr/bin/tedge-dot:
| Package | Source | Pick it when |
|---|---|---|
tedge-dot-c | impl/c/ | Default. ~25x smaller, a glibc 2.17 floor (Debian 8 / RHEL 7 era), amd64, arm64 and armhf packages, and every protocol, including the PROFIBUS-DP preview. |
tedge-dot-rs | impl/rust/ | When you need a feature only the Rust build has: SNMPv3 with SHA-2 or AES-192/256, OPC UA structured values, the deprecated Basic128Rsa15 policy, or CAN FD. |
Not final yet: both packages are maintained and released today, but whether both will remain in the final product is not decided. They implement the same contract, so a config, a flow or a cloud integration built for one carries over to the other.
The two are mutually exclusive — a host installs one or the other — and a
config, a flow or a cloud integration built against one works unchanged against
the other. They run the same e2e, cloud and conformance suites, share the
same golden decode vectors, and are checked against each other for describe
output; the remaining behavioural differences are listed, and enforced by test
tags, in impl/c/README.md.
Everything outside impl/ is shared by both: the contract and docs
(doc/), the device-side flows/, the test stacks and
conformance manifests (connectors/, cloud/), the
demo/ simulators and the packaging/ defaults.
Design in one paragraph
Section titled “Design in one paragraph”The connector is a dumb driver: it reads/writes an OT protocol, decodes
primitives, applies per-signal scaling/units declared on each point, and
publishes generic sample/cmd/status envelopes on
te/device/<device>/ot/<protocol>/.... Everything else — naming, alarms,
child-device registration, cloud operation shaping — lives in protocol-neutral
thin-edge.io flows
(small JavaScript modules, hot-reloaded, no recompilation). One neutral
OT Connector Contract makes every protocol connector look the same to the
rest of the system. See doc/ for the full proposal, RFCs and
machine-readable schemas.
Protocols
Section titled “Protocols”All protocol modules are compiled into the single tedge-dot binary (behind
cargo features in the Rust build, CMake options in the C one); each process runs
one protocol, selected by connector.protocol in its config file.
| Protocol | Transport | tedge-dot-rs | tedge-dot-c |
|---|---|---|---|
| Modbus (reference) | TCP + RTU | ✅ | ✅ |
| OPC UA (None, Basic256Sha256, Aes128/Aes256 policies; username and X.509 users) | opc.tcp | ✅ | ✅ |
| SNMP (v1/v2c/v3: polling, writes, traps and informs) | UDP | ✅ | ✅ (no SHA-2 / AES-192/256) |
| CAN bus (preview) | Linux SocketCAN + DBC | ✅ | ✅ |
| CANopen (preview) | Linux SocketCAN (SDO) | ✅ | ✅ |
| PROFIBUS-DP (preview) | serial (Rust) / tcp:// (C) | ❌ build from source (--features profibus, Linux only) | ✅ |
| AVEVA PI Web API (preview) (batched reads, Channels push, outage backfill, writes) | HTTPS + WebSocket | ✅ | ✅ |
Preview: the CAN bus, CANopen, PROFIBUS-DP and AVEVA PI Web API connectors are tested in CI against simulators (a virtual SocketCAN bus, a PROFIBUS-DP slave simulator and a PI Web API simulator) but not yet on real equipment or a real PI System, so expect rough edges and changes. If you try one on real hardware, please tell us how it went.
Rust modules: connector-modbus, connector-opcua, connector-canbus, connector-canopen, connector-profibus, connector-snmp, connector-aveva-pi. C modules: impl/c/connectors/.
The SNMP connector talks to equipment both ways. It polls objects (GET, GETBULK) and writes
them (SET), and it receives notifications — v1/v2c/v3 traps and informs, which it acknowledges —
on udp/162, matching each device by the address they come from (or, from a trusted forwarder such
as a host snmptrapd, by the snmpTrapAddress.0 they carry). A point is either an object OID
(polled) or a notification and one of its varbinds. SNMPv3 (USM authentication and privacy) works
for both directions. What a notification means — an event, an alarm — is declared on the point and
raised by the flows. The Rust build uses snmp2, the C build a
minimal static net-snmp; see the
SNMP connector spec.
The AVEVA PI Web API connector (preview) reads PI points and AF attributes from a PI System over HTTPS, the first connector for a REST/JSON system rather than a fieldbus. WebIds are looked up once, one request reads all of a device’s points, values are pushed over the PI Web API Channels WebSocket with their PI timestamps, and after an outage or a restart the gap is filled from the PI archive. Basic, Bearer and client-certificate authentication, with secrets in files. See the AVEVA PI connector spec, the guide to writing REST connectors, and migrating from the community plugin. AVEVA and PI Web API are trademarks of AVEVA Group Limited or its subsidiaries.
The OPC UA connector connects to secured servers as well as open ones: it generates (or uses) an
application instance certificate and trusts a server only when its certificate is pinned or
issued by a trusted CA whose CRL it has. An unknown server certificate is quarantined, and the
device’s link status says so; tedge-dot pki trust <thumbprint> trusts it, no restart needed.
tedge-dot pki manages the whole PKI directory (/var/lib/tedge-dot/opcua/pki); see the
OPC UA connector spec.
Install
Section titled “Install”Install tedge-dot-c (or tedge-dot-rs) from the thin-edge.io
community repository,
or grab a .deb/.rpm/.apk (or a plain binary archive) from the
releases page. The package depends on
tedge-parameter-plugin from that same
repository (see Writing to devices), so set the repository up even when
installing a downloaded file. doc/install.md has the details: which of the
two packages to pick, every install route, and upgrading. The package installs:
tedge-dot— the connector binary (also a standaloneread/write/describeCLI);- one default config per protocol in
/etc/tedge/plugins/ot/(no devices configured, so the service starts and idles until you add some); tedge-dot.service— a single systemd service: onetedge-dotprocess runs every configured connector, each in an in-process restart loop;- the flows that map the connector’s generic OT envelopes onto the
thin-edge data model. The core pipeline (measurement, registration, the two
command flows, parameter state) lands in
/etc/tedge/mappers/c8y/flows/ready to run — the mapper hot-reloads it, no restart. The opt-in alarm and event flows wait in/usr/share/tedge-dot/flows/until you copy one over and give it aparams.toml; - demo configs in
/usr/share/tedge-dot/demo/, pre-wired to the Docker simulators in demo/ — see there for the all-protocols demo.
Add [[device]] sections to a config (each file documents the syntax). The service notices the
change and applies it within a few seconds, without restarting. That includes a file written by
thin-edge.io configuration management or any other tool. A reload does the same right away:
sudo systemctl reload tedge-dot # SIGHUP; optional, a change is also detected on its owntedge mqtt sub 'te/+/+/+/+/m/+' # watch the measurements arriveChanges are detected by checking the config files and the point libraries they reference every
TEDGE_DOT_CONFIG_WATCH_INTERVAL (default 2s). To apply changes only on a reload, run the
service with tedge-dot run --no-watch (or set the variable to 0). A
change is applied once it has stopped changing, so a file that is still being written is not
read half-done. Either way the service runs the same reload:
A reload re-reads every config file and the point libraries they reference. A connector whose
configuration is unchanged keeps running untouched; one whose file changed applies it in place,
reconnecting its devices while its MQTT session and service health stay up; a file that cannot
be used is logged and its connector keeps the configuration it has. A new file starts a
connector and a removed file stops its connector. A change of service_name, protocol,
[mqtt] or the effective stall timeout (stall_timeout, raised to twice operation_timeout)
restarts that one connector, and log_level needs a service restart. A connector that cannot
start, or cannot restart, is tried again every TEDGE_DOT_RESTART_DELAY seconds (default 5) and
on every reload; the service keeps running meanwhile. The same goes for a broker that does not
accept the connection within 10 seconds, such as one still starting at boot: the connector
starts once it does.
One point list, many devices
Section titled “One point list, many devices”A device’s point list belongs to its type, not to the instance: every ACME meter of the same firmware has the same registers, and only the address differs. So a point list can live in its own file — a point library — and a device reference it:
[[device]]name = "plc-7"protocol_address = { transport = "tcp", host = "192.168.0.17", port = 502, unit_id = 1 }points_from = ["acme-meter-v2"] # /usr/share/tedge-dot/points.d/modbus/acme-meter-v2.tomlLibraries are looked up as <dir>/<protocol>/<name>.toml under
/etc/tedge/plugins/ot/points.d (a site’s own) and then /usr/share/tedge-dot/points.d
(packaged), so a site copy shadows a packaged list of the same name. A device may reference
several and add or adjust points of its own — the later definition of a point id patches the
earlier one — which is how a packaged list gets extended without editing the file a package
upgrade replaces.
Points can also carry a name and description, so a shared list documents itself once for
every instance that references it — they render into the Cumulocity parameter UI and into the
connector’s retained capability descriptor, rather than being echoed on every sample.
A library can also name the device type it describes ([library] type), which every device
referencing it inherits (a type on the [[device]] wins). That is what keeps two device types
on the same protocol from colliding in the cloud: parameter set names are derived from it, and
it becomes the thin-edge entity type of the registered child device.
The packages ship a library per demo simulator (demo-sim, one for each protocol), so the
quickest way to get data out of a device is to give a [[device]] its address and
points_from = ["demo-sim"] — no point definitions to type. The demo configs in
demo/config/ are written exactly that way.
An OPC UA library can also carry the server’s namespace table ([library] namespaces): its
node ids then stay correct on every instance, however the server numbers its namespaces (see
the OPC UA connector spec §3.9).
Because only the reference is stored, define-device can add an instance at runtime from
its address alone, which is what lets your own discovery (mDNS, a subnet scan, an asset
inventory) onboard a known device type without shipping its point list. See
RFC 0004, the normative
contract §3.4, and
the demo configs in demo/config/ for runnable examples.
Try it without hardware
Section titled “Try it without hardware”Each protocol has a Docker simulator. No broker or cloud needed for a first poke — the CLI talks to the device directly:
just sim modbus # pymodbus simulator on 127.0.0.1:5020export TEDGE_DOT_POINT_LIBRARY_PATH=$PWD/demo/points.d # where the demo point lists live in a checkoutcargo run --manifest-path impl/rust/Cargo.toml -- read -c demo/config/modbus.toml # all devices, all readable pointscargo run --manifest-path impl/rust/Cargo.toml -- read -c demo/config/modbus.toml -d plc1 -p 'temp_*' --poll # keep polling (Ctrl-C stops)cargo run --manifest-path impl/rust/Cargo.toml -- run -c demo/config/modbus.toml --output stdout --duration 10s # sample JSON lines, no brokerSee demo/ for the local exploration guide and the full all-protocols demo on a real device — both use the same configs in demo/config/.
Sending less: report by exception
Section titled “Sending less: report by exception”A point, a device or the whole connector can declare a reporting policy, so a reading is published only when it matters: on a change, beyond a deadband (absolute or a percentage), at most so often, or once a flapping value has settled. A heartbeat still publishes a flat signal now and then, so Cumulocity keeps the device available:
[connector]report = { max_interval = "30m" } # heartbeat (set in the packaged configs)
[[device.point]] id = "boiler_temp" report = { deadband = 0.5, min_interval = "10s" }The SDK runtime applies it the same way for every connector, before anything reaches the broker. See Reducing data volume.
A point can also map its value between the device’s form and a readable one: state codes to labels (with ranges and a catch-all), or numeric text to numbers. The map works both ways, so a label written from the cloud reaches the device as its code:
[[device.point]] id = "op_state" datatype = "uint16" access = "read_write" address = { table = "holding", address = 100, count = 1 } map = { cases = [{ eq = 0, to = "stopped" }, { eq = 1, to = "running" }], default = "unknown" }See Mapping values.
Writing to devices
Section titled “Writing to devices”Writable points (access = "read_write" / "write") are written through retained
thin-edge commands, never through a second protocol session:
# one point (the contract `write` verb)tedge mqtt pub -r te/device/plc1/ot/modbus/cmd/write/w1 '{"status":"init","point":"temp_u16","value":4242}'# several points, in order, one result (the SDK `write-batch` verb)tedge mqtt pub -r te/device/plc1/ot/modbus/cmd/write-batch/b1 '{"status":"init","writes":[{"point":"temp_u16","value":4242},{"point":"coil_rw","value":true}]}'From Cumulocity, writable points are device parameters: the ot-parameter-state flow
keeps one twin fragment per parameter set current, and the command flows turn a
c8y_ParameterUpdate operation from the device’s Parameters tab (mapped by the
tedge-parameter-plugin, which owns that
operation) into one write-batch.
A tenant admin declares the sets once with the definitions tedge-dot describe prints from
the same configuration — by default every connector config in /etc/tedge/plugins/ot, with a
set that several of them share rendered once. A set name is a tenant-wide identifier, so it is derived from the device’s
type rather than from the protocol — acme_meter_v2_control_parameters, not
modbus_parameters — which is what lets several device types on one protocol coexist in a
tenant. A point that needs no set can be published as a fragment of its own with
meta.parameter = { fragment = "pump_speed" }: the managed object then holds "pump_speed": 42
rather than an object around one value. See RFC 0003,
RFC 0005, flows/ and
operations/.
Repository layout
Section titled “Repository layout”| Path | Contents |
|---|---|
| impl/rust/crates/sdk | tedge-dot-sdk — runtime, Connector trait, config model, decode helpers |
| impl/rust/crates/connector-* | one crate per protocol module |
| impl/rust/crates/ot-conformance | ot-conformance — the connector conformance harness (schema, decode vectors, behavioural checks) |
| impl/rust/src/ | the Rust tedge-dot binary (run service, read/write/describe CLI) |
| impl/c/ | the C implementation: SDK, connectors, binary, cross-build and packaging |
| flows/ | protocol-neutral thin-edge.io flows (sample→measurement, alarms, registration, commands) |
| operations/ | Cumulocity operation shims (legacy c8y_* operations and c8y_ParameterUpdate → generic OT commands) |
| connectors/ | per-protocol e2e test stacks: simulator, Docker compose, Robot suites |
| cloud/ | Cumulocity cloud e2e suites (live tenant) |
| packaging/ | installed default configs, systemd unit, package scripts |
| demo/points.d/ | example point libraries (a device type’s point list, packaged on its own) |
| doc/ | proposal, RFCs, contract + schemas, connector specs, testing strategy |
| demo/ | simulator compose file + demo configs: local CLI exploration and the all-protocols on-device demo |
Development
Section titled “Development”Requires Rust (stable) and just; Docker and Python for the e2e suites.
just venv # one virtualenv for every system test (also used by the editor)just test # Rust unit + integration + property testsjust lint # clippy -D warningsjust conformance modbus # full conformance suite (no hardware/broker needed)just test-flows # offline flow tests (tedge flows test)just test-e2e modbus # Dockerised MQTT e2e suite for one protocol (the suite starts its own stack)just test-cloud modbus # live Cumulocity suite (needs C8Y_* credentials; device created per run)just fuzz config_toml # fuzz one SDK target (nightly + cargo-fuzz)just build # cross-compile + package the Rust build (goreleaser)The same system suites run against the C implementation — that is how parity is proven — and it has its own build and packaging recipes:
just c-test # build impl/c/ and run its unit tests (shared golden vectors)just c-describe-parity # `tedge-dot describe` must agree between the two binariesjust conformance-c modbus # the contract conformance suite against the C buildjust test-e2e-c modbus # the SAME e2e suite, C connectorjust test-cloud-c modbus # the SAME cloud suite, C connectorjust c-cross arm64 # cross-compile with zig against the glibc 2.17 floorjust c-package arm64 0.1.0 # deb/rpm/apk for one architecture (nfpm)A test covering a capability one implementation lacks is tagged
requires:<capability> and reported as skipped for that implementation rather
than being duplicated or silently passing; see
connectors/_shared/stack.resource and
C_MISSING_CAPABILITIES in the justfile.
The testing strategy — what each layer catches and what a new connector must ship with — is documented in doc/testing.md. Adding a new protocol is documented in connectors/README.md and doc/connectors/_template-connector-spec.md.
Releasing
Section titled “Releasing”Releases are automated with release-please.
On every push to main it updates a chore: release X.Y.Z pull request whose version
and CHANGELOG.md entry come from the
conventional commits merged since the last
release: fix: bumps the patch, and while below 1.0 so does feat:; a breaking change
(feat!: or a BREAKING CHANGE: footer) bumps the minor. Only feat, fix, perf and
revert appear in the changelog. So the squash-merge title of each PR matters.
A change that needs more than its changelog line (what is new and how to use it, a changed default, a deprecation) also adds a highlight to changes/ in the same PR. The GitHub release shows a short header linking doc/install.md (packaging/release-notes.md), the highlights added since the previous published release, then the changelog (including any tag whose release run failed). See changes/README.md.
Merging the release PR tags the release (no v prefix, e.g. 0.0.14) and creates the
GitHub release, then runs the release workflow, which
builds both implementations — tedge-dot-rs with goreleaser/cargo-zigbuild,
tedge-dot-c with the zig + Debian multiarch image in
impl/c/cross/ and nfpm — and attaches them to that release with one
SHA256SUMS over every asset, then does one Cloudsmith push.
Run the release workflow manually for a snapshot build (it can also refresh the rolling
snapshot pre-release), or give it an existing tag to retry a failed release. The
configuration lives in release-please-config.json and
.release-please-manifest.json; to force a specific
version, add a Release-As: X.Y.Z footer to a commit. The release PR also bumps the
version in impl/rust/Cargo.toml, Cargo.lock and impl/c/CMakeLists.txt, which is what
both builds report as each connector’s capability version; just check-release-versions
keeps them in step (a new workspace crate must be added to the Cargo.lock filter in
release-please-config.json).