tedge-dot — C implementation
A C11 implementation of the tedge-dot SDK framework plus all six connectors — Modbus (libmodbus), OPC UA (open62541), CAN bus (SocketCAN + a minimal DBC parser), CANopen (expedited SDO client directly over SocketCAN), PROFIBUS-DP (a minimal built-in DP-V0 class-1 master, TCP transport), and SNMP (net-snmp, built from source as a minimal static library: polling, writes and v1/v2c/v3 traps and informs). CAN bus, CANopen and PROFIBUS-DP are previews: tested against simulators only, not yet on real equipment (see the README).
It is a maintained peer of the Rust implementation, not a
prototype: it ships as its own package (tedge-dot-c) from the same release,
runs the same system tests, and is held to the same contract. It is the
default package: roughly 25x smaller, it installs on a glibc 2.17 floor and
ships every protocol. Pick the Rust build (tedge-dot-rs) when you need one of
the features it alone has (see the parity table below). Whether both
implementations remain in the final product is not decided yet.
Both packages install the same /usr/bin/tedge-dot, the same systemd unit and
the same /etc/tedge/plugins/ot/ layout, and they conflict with each other:
a host runs one or the other, and a config, a flow or a cloud integration built
against one works unchanged against the other.
It speaks the same OT Connector Contract as the Rust
implementation: same TOML config files (the untouched configs in
demo/config/ work as-is), same MQTT topics, same JSON
sample/command envelopes (including the access and device type echoes and the
runtime-provided write-batch verb that the device-parameter flows rely on, see
RFC 0003 and
RFC 0005, whose parameter derivation,
set naming and tedge-dot describe rendering this build shares), the SDK management verbs
(set-config, define-device, remove-device: the runtime patches the
config document, validates it with the loader and the module, persists it and
live-reloads — comments are not preserved, tomlc99 being read-only), raw
mode and Modbus bit fields, push delivery via OPC UA monitored items, and the
same decode semantics (validated against the Rust SDK’s golden vectors).
Parity with the Rust implementation
Section titled “Parity with the Rust implementation”This table is the single source of truth for what differs:
- everything NOT listed here runs the same suites against both builds — the
e2e Robot suites, the cloud Cumulocity suites, the contract conformance
suite, the shared golden decode vectors and
describeparity; - a capability listed here is declared in
C_MISSING_CAPABILITIESin the justfile, and a test covering it is taggedrequires:<capability>and reported as SKIPPED for the build that lacks it (the convention is documented in connectors/_shared/stack.resource).
A gap is only enforced once a test carries its tag. Three of the entries
below are documented but not yet tested — nothing exercises OPC UA security,
CAN FD or a PROFIBUS serial PHY in either implementation, so skipping them for
the C build is currently a no-op. That is a real hole, and it is kept visible
rather than implied away: just check-capability-tags cross-checks the tags
against the declared lists, fails on a tag no list declares (a typo would
otherwise silently run the test everywhere), and prints the capabilities that
are still inert. CI runs it.
| Capability | Rust | C | Notes |
|---|---|---|---|
| Modbus, OPC UA, CAN bus, CANopen, SNMP | ✅ | ✅ | |
AVEVA PI Web API (aveva-pi) | ✅ | ✅ | Same configuration, capability descriptor, sample envelope (addr, canonical raw, replayed history), cursor file and dedup, paging, fallback, liveness and write bodies; both run the shared decode vectors (tests/aveva_pi_vectors.c, value.rs) and the config scenarios of tests/config.rs (tests/aveva_pi_config.c). libcurl 8.23 on the in-tree mbedTLS. See “AVEVA PI” below for the differences. |
| PROFIBUS-DP | ⚠️ source only | ✅ | The Rust package omits it: its serial dependency has a native libudev build script that does not cross-compile. The C package ships it. |
Push delivery (subscribe) | ✅ | ✅ | OPC UA monitored items; SNMP notifications. See “Push delivery” below for the CAN bus and SNMP differences. |
Point libraries (points_from) | ✅ | ✅ | Same search path, protocol scoping and merge rules (contract §3.4). The mirrored loader tests are library.rs and tests/config.c; the Modbus e2e suite resolves half its points through a library in both builds. |
Value maps (map) | ✅ | ✅ | Same rules and messages (contract §4.3): sdk/src/map.c mirrors map.rs, and both run the shared vectors in doc/contract/test-vectors/map/ (tests/map.c). Conformance check B13 runs against both builds. The C build has no raw (hex) writes, so the vectors’ raw bypass applies to Rust only. as = "string" cannot produce text longer than the 255-byte value buffer, which only a number above about 1e255 needs, so that conversion fails in C. |
operation_timeout | ✅ cancels the call | ✅ bounds the library | See “Liveness” below. |
stall_timeout | ✅ restarts the connector | ⚠️ restarts the process | See “Liveness” below. |
Reload on SIGHUP and on a detected config change | ✅ | ✅ | Both reload when the config files or the point libraries they reference change (TEDGE_DOT_CONFIG_WATCH_INTERVAL, default 2s; run --no-watch or 0 = SIGHUP only), and both idle on an empty config directory until a config appears. Same rules: a new file starts a connector, a removed one stops it, a change is applied in place and an unusable file leaves the running configuration alone. A connector that cannot start or restart is tried again after TEDGE_DOT_RESTART_DELAY and on every reload, while the process keeps running. Two differences:• Unchanged files: Rust compares the resolved configuration, C the documents. A file reordered or respelt to mean the same thing is left alone by Rust but reconnects its devices in C. • With --output stdout (no MQTT session): Rust restarts a connector whose file changed, C applies the change in place.reload_e2e.robot runs against both. |
| OPC UA security (policies, certificates, user identities) | ✅ | ✅ | Same PKI layout, trust rules and reasons: the C build links mbedTLS statically, and ua_pki.c mirrors the Rust trust store. Checked by the shared PKI vectors (tests/opcua_pki.c and connector-opcua/tests/pki.rs), conformance-secure*.toml, the secured e2e suite and ci/pki-parity.sh for tedge-dot pki. ECC policies are in neither build. |
OPC UA method calls (access = "call_method", call-method verb) | ✅ | ✅ | Same addressing, argument conversion, output rendering and failure reasons (doc/connectors/opcua-connector-spec.md §3.10). The client Call service needs no open62541 build flag: UA_ENABLE_METHODCALLS covers only server-side method nodes and stays off. |
snmpv3-sha2 | ✅ SHA224–SHA512, AES192/AES256 | ❌ MD5/SHA + DES/AES | SHA-2 authentication and the Blumenthal AES key extension need a real OpenSSL; this build links net-snmp’s bundled crypto and has no system OpenSSL dependency. A configuration naming one of them loads — one such device must not take a gateway’s whole config down — and that device stays disconnected with a reason naming this capability, while every other device works. Tests needing it are tagged requires:snmpv3-sha2. |
opcua-basic128rsa15 | ✅ | ❌ | open62541 cannot complete a Basic128Rsa15 handshake against the OPC Foundation UA-.NETStandard reference server: it aborts with BadDecodingError where async-opcua connects to the same endpoint, so the fault is in the policy implementation rather than the server or the configuration. The policy is deprecated — open62541 itself warns that its encryption is broken — and is refused anyway unless a device sets allow_deprecated_security. Basic256, the other deprecated policy, works in both builds. Tests needing it are tagged requires:opcua-basic128rsa15. |
OPC UA structured values (opcua-structures) | ✅ | ✅ | address.field points decoded from the server’s DataTypeDefinition, raw points carrying the encoded body with addr.encoding_id/addr.data_type, array elements by address.index, and the built-in types without an SDK primitive of their own (DateTime, LocalizedText, StatusCode, Guid, NodeId, ExpandedNodeId, QualifiedName), with one ReadValueId per node and index in a poll (openspec changes opcua-custom-datatypes, opcua-structures-c). structure.c mirrors structure.rs; both run the vectors in doc/contract/test-vectors/opcua-struct/ (tests/opcua_struct.c) and have a fuzz target (tests/fuzz_opcua_struct.c). C limits, from the fixed-size value: a raw structure body over 256 bytes and a bytes value over 127 bytes are bad samples that name the limit, never truncated; a String or LocalizedText value is cut at 255 bytes like any C string. Monitored items stay one per point (see “Push delivery”). |
canbus-fd | ✅ | ❌ | Classic CAN frames only. No test yet. |
profibus-serial | ✅ serial + tcp:// | ❌ tcp:// only | No serial PHY and no FDL token timing — fine against a device server or the simulator, not yet for a multi-master RS-485 bus. No test yet. |
| CANopen segmented SDO | ❌ | ❌ | Neither implements it; expedited transfers (≤ 4 bytes) only. Not a parity gap. |
stale quality | ❌ | ❌ | Neither emits it; the contract allows it, no last-good cache exists. Not a parity gap. |
| String / raw value length | unbounded | 255 / 256 bytes | Fixed buffers (tdot_value_t.str, TDOT_RAW_MAX): a longer string value is truncated to 255 bytes — an SNMP OCTET STRING at a UTF-8 character boundary — and raw to 256 bytes. |
Push delivery
Section titled “Push delivery”OPC UA points are delivered by monitored items, as in the Rust module:
one subscription per device publishing at the fastest requested point rate, one
monitored item per point (Rust shares one item between points on the same node and
array element), and the point’s own poll_interval as the sampling
hint. Points opting out with subscribe = false stay on the polling schedule,
as does every point of a device whose subscription could not be established.
The C runtime is one thread per config with no async machinery, so where Rust
returns a stream the runtime selects on, the C vtable splits the job in two:
subscribe_device() arms the subscription and drain_subscriptions() is called
once per loop tick to hand over what arrived. The samples are published from the
runtime loop either way, so a module never touches MQTT.
The loop waits for the MQTT socket between ticks, not for the protocol’s socket.
So a pushed value can sit unread until the next tick. The wait therefore follows
the fastest effective sampling_interval among the connector’s pushed points,
kept between 10 ms and 200 ms (sampling_interval = "0" gives 10 ms). A connector
without pushed points keeps the 200 ms tick. A tick visits only the points that
have work: the polled points and the points with a report policy
(sdk/include/tedge_dot/schedule.h). Its cost follows what is published, not
how many points are configured.
Push delivery has a failure mode worth naming, because it is invisible by
construction: a subscribed point is off the polling schedule, so if push stops
the device simply goes quiet behind a retained connected link. open62541 does
not surface every way that can happen through UA_Client_run_iterate, which
keeps returning GOOD when a subscription is deleted server-side or dies with
the session. The connector therefore also watches the subscription’s
delete/status-change/inactivity callbacks and the session state, and treats any
of them as a dead link so the ordinary reconnect-and-re-subscribe path runs.
None of those checks is currently proven by a test, and it is worth being
precise about why. The OPC UA suite does cover push recovery end to end
(Push Delivery Recovers After The Server Restarts and ... From A Silent Server), but each of those trips an older path: a restart resets the TCP
connection, so UA_Client_run_iterate returns non-GOOD; a freeze trips the
client’s request timeout on the polled points first. Verified by removing the
new checks — both tests still pass. Provoking the remaining path needs a server
that deletes a subscription while keeping the session up, which the suite’s
simulator cannot do (tracked in TODO.md). Until then these checks rest on
open62541’s source, not on evidence.
The CAN bus module is the one place the two still differ in delivery: the
Rust module pushes frames as they arrive, while this one renders the push-based
bus as drain-into-cache polling (read_point waits up to ~1.2 s for the first
broadcast of a frame). The observable samples are the same, which is why the
shared e2e suite passes for both and no capability tag is needed; the difference
is latency under a slow poll interval.
The SNMP notification listener has no transport of its own to wait on, so it
receives on the runtime thread (the device’s object points are polled as usual): each drain_subscriptions() reads every pending
datagram from the connector’s one UDP socket, routes it by source address to its
device’s queue (at most 256 notifications, oldest dropped) and hands the drained
device its queue. A sample can therefore be up to one tick (200 ms, or the
fastest pushed point’s sampling interval if that is shorter) later than in
Rust, and its ts is when it was handed over rather than when the datagram
arrived. An inform is acknowledged as its datagram is read, so the
acknowledgement shares that delay.
AVEVA PI
Section titled “AVEVA PI”The module (connectors/aveva-pi/) keeps the Rust crate’s layers:
pi_http.c (libcurl: requests, TLS, authentication, chunking, the Channels WebSocket),
pi_value.c (the pure decoder and the canonical JSON text), pi_push.c / pi_cursor.c /
pi_ring.c (push delivery, recorded polling, backfill) and connector_aveva_pi.c (the
configuration, WebId resolution and the vtable). Snapshot reads and writes run on the runtime
thread; the chunks of one read_points call run in parallel on one curl multi handle, so the
call takes at most one request_timeout. All push traffic, catch-ups included, runs on one
worker thread per subscribed device. The worker queues each accepted event, as canonical JSON
text, in a ring with the OPC UA bounds (max(65536, 4 × points)) and waits while the ring is
above half its bound, so a catch-up is paced by the runtime and never drops
(tests/aveva_pi_push.c pushes three bounds’ worth through a slow
consumer). drain_subscriptions decodes what is queued, hands it to the sink and advances the
cursors, which the worker writes to the cursor file. Because dedup has to see what is queued but
not yet drained, the worker keeps its own copy of the cursors, advanced as it queues.
Where the C module differs from the Rust one:
- Connection pool. Each device has its own curl handles (one multi handle for the runtime
thread, one for its worker), where Rust shares one HTTP client between devices with the same
(base_url, auth, tls). tls.ca_fileis the CA bundle curl verifies against (CURLOPT_CAINFO); Rust adds it to the system roots. Without it, C uses the first of/etc/ssl/certs/ca-certificates.crt,/etc/pki/tls/certs/ca-bundle.crt,/etc/ssl/ca-bundle.pemand/etc/ssl/cert.pemthat exists, since libcurl is built without a compiled-in path.- Error texts of transport and TLS failures are curl’s and mbedTLS’s, not reqwest’s and
rustls’s. The PI-level texts (
unauthorized,HTTP 404: <Errors>,not found: <path>, the decoder’s errors,no message or heartbeat for 1.2s) are the same. A refused WebSocket upgrade is logged with its status but without PI’sErrorstext, which curl does not read from a refused upgrade. - Units. The C SDK has no per-sample unit, so a point that configures no
unittakes PI’s (the point’sEngineeringUnitsor the attribute’s default unit, else the first value’sUnitsAbbreviation) as its point unit on connect. Rust sets it per sample, so only a value whoseUnitsAbbreviationchanges while the point has no PI unit would differ. - Unresolved pushed points stay on the polling schedule and report
not found: <path>on every poll; once aresolve_retrylookup finds one, the drain reports the link down so the device reconnects and subscribes again. Rust delivers onenot foundsample and re-subscribes fromcheck_subscription. - A
read_pointscall whose every chunk failed hands over abadsample per point (the failure aserror) before the link goes down; Rust returns the transport error alone. - Lengths: string values,
raw(the canonical JSON text of PI’sValue) and errors are truncated at the SDK’s buffer sizes (see “String / raw value length”). - Dedup compares values by their canonical text, so
1and1.0at one timestamp are the same value in C and two values in Rust (serde_json tells integers from floats).
Liveness
Section titled “Liveness”operation_timeout is the contract’s bound on one protocol-module call
(§8.1). Rust wraps each call in a cancellable timeout. This runtime cannot
cancel a call in flight — a blocking libmodbus/open62541 call owns the thread —
so it pushes the value down into the protocol library’s own response timeout
instead (modbus_set_response_timeout, open62541’s client timeout). That is
what actually makes a silent peer return, and it is why the conformance
suite’s silent-peer checks (B5) pass for both builds.
stall_timeout covers what a response timeout cannot: a call that wedges
inside a library. Every poll loop stamps a heartbeat each tick and after each
read, so a long pass that keeps making progress does not count; a watchdog thread
notices when one stops advancing and exits the process (exit code 70) so the
service manager restarts it — packaging/tedge-dot.service sets
Restart=always. Rust cancels and restarts just the wedged connector while its
siblings keep running; the C build takes every connector in the process down
with it, because a pthread blocked in a protocol library cannot be safely
cancelled. Both end with the broker publishing the retained last-will health
down. stall_timeout = "0" disables the watchdog; a value below twice
operation_timeout is raised, so one slow-but-legitimate call cannot cause a
restart loop.
The C build is held to the same coverage as the Rust one:
-
unit tests —
just c-test(ctest --test-dir impl/c/build): the golden decode vectors shared with the Rust SDK; the device-parameter/describechecks, which assert the same facts over the same fixture config asimpl/rust/crates/sdk/src/descriptor.rs; and the config-loader rules the runtime depends on (the liveness bounds and the per-point sampling hint,tests/config.c); and the SNMP decoder and conversions over the trap vectors shared with the Rust crate, its configuration rules and an in-process loopback receive (tests/snmp.c); and the write direction of the per-point transform: the inverse math, integer rounding and the one write path thewrite/write-batchverbs and the CLI share (tests/write.c); -
describe parity —
ci/describe-parity.sh(just c-describe-parity) renders the Cumulocity DTM definitions of every connector config in the repo with both binaries and compares them parsed, so the tenant-side declaration cannot drift between the implementations; CI runs it in thecjob; -
e2e Robot suites — the per-protocol stacks under connectors/ run their Robot suite against the C connector with
just test-e2e-c <proto>(the stack’sconnectorservice is swapped forconnectors/_shared/Dockerfile.connector-c); CI runs it for every protocol (e2e-cmatrix); -
cloud Robot suites — the live Cumulocity harness under cloud/ builds this implementation into the thin-edge demo image with
just test-cloud-c <proto>(IMPL=cselects a connector-install stage that compiles impl/c/ instead of installing the Rust package), so child registration, measurements, operations, the Cloud Fieldbus import and the device-parameter round-trip are all covered for the C build too; -
smoke —
ci/smoke.sh <proto>, a fast broker-and-simulator check without Docker for the connector itself (used by thecCI job). -
conformance — the contract conformance suite (layers 1-3, built-in broker and simulators) runs against the C binary as an external connector:
just conformance-c modbus|opcua(manifestsconnectors/<proto>/conformance-c.toml, identical claims to the Rust ones); CI runs it in thecjob. Both connectors are fully conformant, hot reload through the management verbs included.
Liveness is covered by operation_timeout and stall_timeout — see
Liveness above for how they differ from the Rust runtime’s.
Debugging: TDOT_OPCUA_DEBUG=1 keeps open62541’s client handshake log on stdout.
The cli-cleanup and management-cleanup CTest cases cover resource cleanup on CLI
errors and failed configuration writes, including preservation of the original I/O error.
Run them with ctest --test-dir build --output-on-failure -R cleanup; an
AddressSanitizer build (-DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer")
also checks their heap cleanup with ASAN_OPTIONS=detect_leaks=1.
Packaging & releases
Section titled “Packaging & releases”Both implementations are released together by
.github/workflows/release.yaml: a
release tag (v1.2.3 or the bare 0.0.1-alpha.3 form this repository has used
so far) builds tedge-dot-rs (goreleaser) and tedge-dot-c (this tree), and one
publish job assembles a single GitHub release, a single SHA256SUMS over every
asset and a single Cloudsmith push. A manual run builds both and can refresh the
rolling snapshot pre-release.
The C package ships .deb / .rpm / .apk + tarballs for amd64, arm64 and
armhf. It carries the same /usr/bin/tedge-dot, the same tedge-dot.service
and the same /etc/tedge/plugins/ot/ layout as tedge-dot-rs, which is why the
two conflict — install one or the other. Same device-side
flows too: the core pipeline is deployed active into
/etc/tedge/mappers/c8y/flows/ and the opt-in alarm/event flows ship in
/usr/share/tedge-dot/flows/. It additionally ships the PROFIBUS connector,
which the Rust package omits.
Packaging is done with nfpm rather than goreleaser: goreleaser’s headline feature is cross-compiling Go/Rust with zig, and it has no notion of a C build that also links system shared libraries. The build itself does use zig — see below.
Cross-compilation
Section titled “Cross-compilation”Every architecture is cross-compiled on one machine by cross/: a
Debian container where zig supplies the compiler and libc and Debian
supplies the target-architecture shared libraries (libmodbus,
libmosquitto, libcjson) via multiarch. open62541 is not packaged by Debian,
so it is still built from source — cross-compiled with the same toolchain and
statically linked.
The point is not only that one runner builds every architecture. zig lets us
pin the minimum glibc (2.17 — Debian 8 / RHEL 7 era), which a native build
fundamentally cannot: a binary built on ubuntu-24.04 hard-requires glibc 2.39
on the target, which rules out most OT gateways in the field.
just c-cross-image # build the toolchain imagejust c-cross arm64 # -> impl/c/dist/arm64/tedge-dotjust c-verify arm64 # golden vectors on Debian bullseye (glibc 2.31)just c-package arm64 0.1.0 # -> impl/c/dist/packages/*.{deb,rpm,apk}just c-cross-all # every architecture in C_ARCHSjust c-verify for a non-native architecture needs binfmt/qemu registered:
docker run --privileged --rm tonistiigi/binfmt --install all.
The packaged systemd unit runs tedge-dot run /etc/tedge/plugins/ot — a
directory, so one process runs every connector config it contains (one
worker thread per file), matching the Rust single-service model.
Layout
Section titled “Layout”| Path | Contents | Rust counterpart |
|---|---|---|
sdk/include/tedge_dot/ | public headers: model, config, connector vtable, decode, runtime | impl/rust/crates/sdk |
sdk/src/ | config loader (tomlc99), decode/encode, envelope builder (cJSON), poll-loop runtime (mosquitto), device parameters + Cumulocity DTM rendering | impl/rust/crates/sdk |
connectors/modbus/ | libmodbus connector (TCP + RTU, 4 tables, typed decode, writes) | impl/rust/crates/connector-modbus |
connectors/opcua/ | open62541 connector (client session, node-id points, typed reads/writes) | impl/rust/crates/connector-opcua |
connectors/canbus/ | SocketCAN connector + minimal DBC parser (BO_/SG_, Intel & Motorola layouts) | impl/rust/crates/connector-canbus |
connectors/canopen/ | expedited SDO client over raw SocketCAN (no CANopen library) | impl/rust/crates/connector-canopen |
connectors/profibus/ | minimal DP-V0 master (Diag→Prm→Cfg→Data_Exchange, bus thread, tcp:// transport) | impl/rust/crates/connector-profibus |
connectors/snmp/ | SNMP over net-snmp (third_party/net-snmp/, static, patched): batched GET/GETBULK and SET, one UDP listener for v1/v2c/v3 traps and informs, routing by source or snmpTrapAddress.0, community/USM checks, inform Response and v3 Report | impl/rust/crates/connector-snmp |
connectors/aveva-pi/ | AVEVA PI Web API on libcurl (cmake/curl.cmake): batched snapshot reads on a multi handle, writes, Channels WebSocket and recorded polling with outage backfill on a worker thread per device, delivery cursors | impl/rust/crates/connector-aveva-pi |
src/main.c | read / write / run / describe CLI | src/main.rs |
tests/golden.c | conformance runner for impl/rust/crates/sdk/conformance/vectors.json | tests/golden_vectors.rs |
tests/describe.c | device-parameter derivation + DTM rendering checks | impl/rust/crates/sdk/src/descriptor.rs tests |
tests/write.c | inverse transform on write (tdot_transform_invert, tdot_connector_write) | sdk/src/model.rs / sdk/src/connector.rs tests |
tests/snmp.c | SNMP golden vectors (connectors/snmp/conformance/trap-vectors.json), config rules, loopback receive | connector-snmp unit tests |
tests/aveva_pi_*.c | AVEVA PI decode vectors (connectors/aveva-pi/conformance/decode-vectors.json), canonical numbers and write encoding; configuration errors; cursors and the push ring; the libcurl feature set | connector-aveva-pi unit and tests/config.rs tests |
ci/describe-parity.sh | tedge-dot describe output compared between the Rust and C binaries | — |
ci/smoke.sh | e2e smoke: connector ⇄ simulator ⇄ broker, per protocol (used by the c CI job) | conformance/e2e suites |
cross/ | zig + Debian-multiarch cross-compilation image, build and verify scripts | goreleaser/zig |
third_party/net-snmp/ | the patch CMake applies to net-snmp 5.9.4 before building it: the decode strictness the shared vectors require, and AES-128 privacy for the bundled crypto (upstream wires AES into sc_encrypt/sc_decrypt for a real OpenSSL only, so an AES user would otherwise send its scoped PDU unencrypted) | impl/rust/vendor/snmp2 |
third_party/tomlc99/ | vendored TOML parser (MIT) | serde/toml |
The Rust Connector trait maps to a C vtable (tdot_connector_t in
connector.h): configure,
connect_device, read_point, write_point, disconnect_device, plus the
optional device_info (the link status info descriptor, which the c8y
registration flow turns into the c8y_ModbusDevice twin fragment). Protocol
modules are selected by tdot_connector_factory(protocol) and compiled in
behind CMake options (-DTDOT_MODBUS=ON/OFF, -DTDOT_OPCUA=ON/OFF, -DTDOT_SNMP=ON/OFF, …) —
the C analogue of the cargo feature flags.
Build & run
Section titled “Build & run”Dependencies: cmake, pkg-config, libmodbus, mosquitto (client lib), cJSON.
open62541 is used from the system when installed, otherwise CMake builds
v1.5.5 from source (client-only feature set) via FetchContent. The CAN
connectors are Linux-only (SocketCAN) and compile-gated automatically.
On macOS: brew install libmodbus open62541 mosquitto cjson.
cmake -B build -G Ninja # MinSizeRel by defaultcmake --build build
# against the demo simulators (`just sim modbus`, `just sim opcua`):./build/tedge-dot read -c ../../demo/config/modbus.toml./build/tedge-dot read -c ../../demo/config/opcua.toml -d opc1 -p temperature --json./build/tedge-dot write -c ../../demo/config/modbus.toml -d plc1 -p coil_rw --value true./build/tedge-dot run -c ../../demo/config/modbus.toml --output stdout --duration 10s./build/tedge-dot run -c ../../demo/config/opcua.toml # publishes to MQTT broker
# the writable points as Cumulocity DTM property definitions, for a tenant admin to# register once (needs no device, broker or protocol module):./build/tedge-dot describe -c ../../demo/config/modbus.toml./build/tedge-dot describe -c ../../demo/config/modbus.toml -d plc1 --compact./build/tedge-dot describe -c ../../demo/config # every config in the directory, sets merged
# tests./build/tedge-dot-golden ../rust/crates/sdk/conformance/vectors.json./build/tedge-dot-describeWhat was verified (2026-07-02/03, against the demo simulators)
Section titled “What was verified (2026-07-02/03, against the demo simulators)”- Modbus: all six demo points read correctly (uint16 17001, scaled 17.001 °C
with
decimal_shift = -3, uint32 617001, float32 404.17, coil), Modbus exception onbad_point→quality: "bad"with error reason, register + coil write round-trips. - OPC UA: float64/uint32/int32/bool reads,
BadNodeIdUnknown→quality: "bad", int32 + bool write round-trips. - CAN bus (Linux/vcan0): RPM 2500, coolant 85, brake true decoded from the
sim’s ENGINE_STATUS broadcasts via the DBC; a write sends the encoded
read-modify-write frame on the bus. DBC bit-layout logic (Intel + Motorola,
signed, encode round-trips) covered by a self-test against
sim/test.dbc. - CANopen (Linux/vcan0): SDO expedited uploads (uint16 1234, int16 -100,
uint8 1) and a download round-trip (
digital_out1→0→1); SDO aborts map toquality: "bad"with the abort code. - PROFIBUS-DP (TCP): full Diag→Set_Prm→Chk_Cfg→Data_Exchange bring-up against the DP-V0 slave sim; seeded inputs decode (0x0A, bit 3, 0x1234, 100), output-byte write lands in the slave’s PI_Q, and back-to-back sessions reconnect cleanly.
- MQTT contract: retained health (
up/down+ last-will), retained capability descriptor, retained per-device link status, non-retained samples onte/device/<dev>/ot/<protocol>/sample/<point>, and inboundcmd/write/<id>commands answered with retained{"status":"successful"|"failed"}results. - Recovery: killing the simulator flips the link to
disconnectedand starts the 1s→60s exponential reconnect backoff. - Decode conformance: all 73 golden vectors pass (every datatype, endianness/word-order combination, NaN/±Inf, out-of-JS-safe-range 64-bit → string, bitfields, round-trips).
Size comparison
Section titled “Size comparison”Apples-to-apples is tricky (the Rust binary is one static binary with five protocols; the C build dynamically links two protocol libraries), but the totals on this machine:
| size | |
|---|---|
Rust tedge-dot release binary (aarch64-linux, 5 protocols, static) | 12 MB |
| C binary, all 5 protocols, open62541 statically linked (x86_64 Linux, MinSizeRel) | 457 KB |
| C binary, modbus + opcua, shared open62541 (arm64 macOS) | 108 KB |
| C binary, modbus only | 91 KB |
| + libmodbus.dylib | 75 KB |
| + libmosquitto.dylib | 160 KB |
| + libcjson.dylib | 88 KB |
The headline number: all five protocols in 457 KB (open62541 built client-only and statically linked; libmodbus/mosquitto/cJSON still dynamic, ~320 KB more if counted) — about 25× smaller than the Rust binary. The canbus/canopen/profibus connectors add ~40 KB total because their protocol logic (DBC parsing, SDO framing, DP-V0 master) is implemented directly rather than pulled in as libraries.
Microcontroller path
Section titled “Microcontroller path”What this implementation shows about the MCU question:
- The contract layer is MCU-ready. The decode/encode/scaling core and the envelope model have no OS dependencies beyond libc (the golden-vector suite would run on bare metal). tomlc99 and cJSON are portable C99 with small footprints.
- open62541 explicitly supports MCUs (it has ports for FreeRTOS+lwIP, Zephyr) — the OPC UA connector logic here uses only the high-level client API that exists on those ports.
- The Modbus connector would swap libmodbus for a bare-metal Modbus layer on MCUs (libmodbus assumes POSIX sockets/termios). Modbus framing is simple enough that this is a small, well-bounded port.
- CANopen and PROFIBUS already have zero library dependencies — the SDO client and the DP-V0 master are plain C over a socket/byte stream, so on an MCU only the transport (CAN driver / UART) needs swapping. The DBC parser is libc-only.
- The runtime (poll loop, backoff, scheduler) is a single thread with no
dynamic task machinery — it maps naturally onto an RTOS task. The MQTT
output would use an embedded client (e.g. coreMQTT) behind the same
publish()seam inruntime.c.
Known limitations
Section titled “Known limitations”The differences from the Rust implementation are listed in the parity table above, which is the source of truth. Limitations of this build that are NOT parity gaps:
- CANopen is expedited-SDO only (values ≤ 4 bytes; segmented transfers report a bad sample). The Rust module does not implement segmented transfers either.
- Fixed-size buffers cap string values at 255 bytes and raw values at
256 bytes (
tdot_value_t.str,TDOT_RAW_MAX), where the Rust model is unbounded. Abytesvalue (hex in that string) holds at most 127 bytes; a longer one, like a raw OPC UA structure body over 256 bytes, is a bad sample that names the limit rather than a truncated value. stalequality (last-good cache) is not implemented — nor is it in the Rust build; the contract allows it, neither emits it.
Licensing
Section titled “Licensing”Everything linked here is copyleft-safe for the intended packaging: libmodbus (LGPL-2.1+, dynamically linked), open62541 (MPL-2.0), mosquitto client library (EPL/EDL dual), cJSON (MIT), tomlc99 (MIT, vendored).