Modbus Connector — Reference Implementation Spec
| Field | Value |
|---|---|
| Status | Implementable draft |
| Protocol id | modbus |
| Crate | connector-modbus (feature modbus) |
| Builds on | tokio-modbus, Connector SDK |
| Implements | OT Connector Contract v0.4.0 |
This is the reference connector. It is written to be precise enough that a competent developer — or an AI coding agent — can implement it from this document plus the SDK spec, and verify correctness against the acceptance vectors without hardware. Wherever the contract leaves something protocol-specific, this document fills it in.
1. Scope
Section titled “1. Scope”The Modbus connector reads and writes the four standard Modbus data tables over TCP or RTU,
and publishes sample envelopes.
Apart from the declared per-point linear transform (point.transform, contract §4.2, applied via
the SDK), all renaming, units, alarms, events, and thin-edge JSON shaping are out of scope
and handled by flows.
Supported Modbus operations (via tokio-modbus):
| Table | Read | Write | tokio-modbus call |
|---|---|---|---|
| Coil | yes | yes | read_coils, write_single_coil / write_multiple_coils |
| Discrete input | yes | no | read_discrete_inputs |
| Holding register | yes | yes | read_holding_registers, write_single_register / write_multiple_registers |
| Input register | yes | no | read_input_registers |
2. Capability descriptor
Section titled “2. Capability descriptor”The connector MUST publish:
{ "protocol": "modbus", "version": "0.1.0", "modes": ["raw", "typed"], "datatypes": ["bool", "int16", "uint16", "int32", "uint32", "int64", "uint64", "float32", "float64"], "point_kinds": ["coil", "discrete_input", "holding_register", "input_register"], "command_verbs": ["write", "set-config", "define-device", "remove-device"], "features": ["polling", "bitfield", "management"], "subscribe": false}Modbus is strictly polled, so subscribe is false and the connector only implements
read_points (not subscribe). The module itself declares only write; the SDK runtime adds the
set-config/define-device/remove-device management verbs (and the management feature) it
implements on every connector’s behalf (contract §6.3).
3. Protocol-specific configuration
Section titled “3. Protocol-specific configuration”These objects fill the contract’s opaque slots. They MUST be schema-validated by modbus.schema.json.
3.1 connection (shared defaults)
Section titled “3.1 connection (shared defaults)”[connection]request_timeout_s = 5.0 # per-request bound (see below); default 5s
[connection.serial] # RTU defaults, used when a device omits thembaudrate = 9600parity = "N" # "N" | "E" | "O"stopbits = 2 # 1 | 2databits = 8 # 7 | 8request_timeout_s bounds a single Modbus request. tokio-modbus has none of its own, so
without it a request to a peer that accepts bytes and never answers — a half-open socket after
the device or its container vanished, with no RST to end it — waits forever and wedges the
connector’s poll loop (see the SDK’s liveness section). On a timeout the point is reported
bad with the reason, the rest of the batch is reported as skipped rather than waiting again,
and the runtime’s degraded-link handling re-establishes the transport: a cancelled request
leaves the connection mid-frame, so it must not be reused.
3.2 device.protocol_address
Section titled “3.2 device.protocol_address”TCP:
protocol_address = { transport = "tcp", host = "192.168.0.10", port = 502, unit_id = 1 }RTU:
protocol_address = { transport = "rtu", serial_port = "/dev/ttyRS485", unit_id = 1, baudrate = 19200, parity = "N", stopbits = 1, databits = 8 }| Field | Required | Notes |
|---|---|---|
transport | yes | "tcp" or "rtu". |
unit_id | yes | Modbus slave/unit id (0–247). |
host, port | tcp | port default 502. |
serial_port | rtu | e.g. /dev/ttyRS485. |
baudrate/parity/stopbits/databits | rtu | Override connection.serial. |
3.3 point.address
Section titled “3.3 point.address”address = { table = "holding", address = 7, count = 2, start_bit = 0, bit_count = 16 }| Field | Required | Notes |
|---|---|---|
table | yes | "coil", "discrete_input", "holding", "input". |
address | yes | 0-based register/coil address. |
count | typed/raw | Number of registers (holding/input) or coils to read. For typed, MUST be large enough for the datatype (see §4.2). Defaults: 1 for coils/bool, derived from datatype otherwise. |
start_bit | no | For bitfield extraction within registers (0-based). |
bit_count | no | Width of the bit-field. Requires bitfield feature. |
4. Decoding rules (typed mode)
Section titled “4. Decoding rules (typed mode)”In typed mode the connector reads raw 16-bit registers (or coil bits), then calls the SDK
helper decode_primitive(bytes, datatype, endianness, word_order). The connector itself does
no arithmetic beyond assembling bytes.
4.1 Byte and word order
Section titled “4.1 Byte and word order”- A Modbus register is 16 bits and transmitted big-endian on the wire.
tokio-modbusreturns each register as au16already in host order. endianness(bigdefault) controls byte order within each 16-bit register when the connector serializes registers to a byte buffer for decoding.big=[hi, lo],little=[lo, hi].word_order(bigdefault) controls the order of multiple registers for 32/64-bit values.big= first register is most-significant word;little= first register is least-significant word.
4.2 Datatype → register count
Section titled “4.2 Datatype → register count”datatype | Registers (count) | Coils |
|---|---|---|
bool | — | 1 |
int16 / uint16 | 1 | — |
int32 / uint32 | 2 | — |
int64 / uint64 | 4 | — |
float32 | 2 | — |
float64 | 4 | — |
string | ceil(len/2) | — |
bytes | count | — |
For bool, the connector reads from coil or discrete_input and maps 1→true, 0→false.
4.3 64-bit and string representation
Section titled “4.3 64-bit and string representation”int64/uint64outside the JS safe-integer range MUST be emitted as a stringvaluewithvalue_repr: "string"(per contract §4.1).stringis decoded using the connector’s declared text encoding (default ASCII/UTF-8, null-trimmed); emitted asvaluewithvalue_repr: "string".bytesalways emits novalue; the data is therawhex.
4.4 Bit-fields (optional bitfield feature)
Section titled “4.4 Bit-fields (optional bitfield feature)”When start_bit/bit_count are present, the connector extracts that bit-field from the
assembled buffer (after endianness/word-order) and decodes it as an unsigned integer of
bit_count width. This is the only arithmetic the connector performs and exists because
bit extraction in JavaScript is error-prone.
5. Read flow (read_points)
Section titled “5. Read flow (read_points)”- Group the due points for the device by
tableand into contiguous address ranges to minimize Modbus transactions (mirrors today’s_build_query_modelbatching). - Issue the batched
tokio-modbusreads for each range. - For each point:
rawmode → buildrawhex from the bytes;quality = good; novalue.typedmode → assemble bytes perendianness/word_order, calldecode_primitive, setvalue+value_repr+datatype;quality = good.- On a Modbus exception/timeout/CRC error for a range → emit
badsamples for the affected points witherrorset and novalue.
- Echo the address into
addr:{ "table": ..., "address": ..., "unit_id": ... }. - Return one
Sampleper requested point.
The connector MUST NOT drop failed reads silently; it emits bad samples so flows/operators
can react. Repeated identical bad samples MAY be rate-limited by the SDK.
5.1 Reporting policy (report)
Section titled “5.1 Reporting policy (report)”The connector returns every reading; the SDK runtime applies the point’s report table
(contract §5.3) before a sample is published. See Reducing data volume.
Every Modbus point is polled, so a heartbeat (max_interval) is the point’s next poll, and
needs no extra protocol traffic. The packaged config sets [connector] report = { max_interval = "30m" }.
5.2 Value mapping (map)
Section titled “5.2 Value mapping (map)”The connector decodes the register. The SDK runtime then applies the point’s map
(contract §4.3), after the transform, and maps a written label back to its code before
the connector encodes it. Status and operating-state registers are the common case, for example
map.cases = [{ eq = 0, to = "stopped" }, { eq = 1, to = "running" }] on a uint16 holding
register. A coil maps from a bool (eq = true). See Mapping values.
6. Write flow (execute, verb write)
Section titled “6. Write flow (execute, verb write)”For cmd/write/<id> with status: "init":
- Resolve the target point by
request.pointwithin the device. - Reject (
failed) if the point’saccessisread. - Typed write:
request.valueis in engineering units; the SDK first maps it back through the point’stransform(contract §4.2), then the connector encodes that raw value usingencode_primitive(value, datatype, endianness, word_order), then:- holding register, 1 register →
write_single_register; - holding register, >1 register →
write_multiple_registers; - coil →
write_single_coil(value must bebool/0|1).
- holding register, 1 register →
- Raw write: take
request.raw(hex), split into registers/coil bits, write verbatim. - Bit-field write: read-modify-write — read the current register(s), replace the
start_bit..start_bit+bit_countfield with the supplied value, write back. (Mirrors today’scompute_masked_value.) - Return
successfulwith the writtenvalue/raw, orfailedwithreason.
7. Status
Section titled “7. Status”- Publish
te/device/<device>/ot/modbus/status/linkretained:connectedonce a device responds,disconnectedon transport loss,degradedif some reads fail persistently. - Publish service health on
te/device/main/service/<service>/status/health(<service>is[connector] service_name, defaulttedge-dot-modbus).
8. Mapping from the legacy plugin
Section titled “8. Mapping from the legacy plugin”For reviewers familiar with the Python plugin, this is where each old responsibility goes:
| Legacy (Python) | New home |
|---|---|
reader.py transport, batching | Modbus connector read_points |
mapper.py datatype/endianness decode | SDK decode_primitive (driven by config) |
mapper.py scaling (raw*m*10^d/div)+offset | Connector (per-point transform, contract §4.2) |
mapper.py alarm/event state machine | Flow (modbus-alarm) |
mapper.py thin-edge JSON templatestring | Flow (ot-measurement template) |
mapper.py combinemeasurements | Flow (aggregation/grouping) |
operations/set_register.py, set_coil.py | execute verb write |
operations/c8y_* cloud glue | Flow + thin-edge operations (see migration guide) |
devices.toml / modbus.toml | New connector config (contract §3) |
9. Acceptance test vectors
Section titled “9. Acceptance test vectors”These vectors are normative for the Modbus connector and feed directly into the
conformance suite. Each gives the registers/coils as
returned by tokio-modbus, the point config, and the exact expected Sample fields. Hex is
lowercase, words space-separated per 16-bit register.
All float/integer vectors below were computed with IEEE-754 / two’s-complement and are reproducible with
structin Python orf32::from_bitsin Rust.
9.1 uint16, big-endian
Section titled “9.1 uint16, big-endian”- Registers:
[0x1234] - Point:
{ mode: typed, datatype: uint16, endianness: big, address: { table: holding, address: 0, count: 1 } } - Expect:
value = 4660,value_repr = "number",raw = "1234",quality = "good".
9.2 int16, signed negative
Section titled “9.2 int16, signed negative”- Registers:
[0xfffe] - Point:
{ mode: typed, datatype: int16, endianness: big } - Expect:
value = -2,raw = "fffe".
9.3 uint16, little byte order
Section titled “9.3 uint16, little byte order”-
Wire bytes for the register assembled little-endian: register
0x1234withendianness: littleserializes to bytes34 12. -
Registers:
[0x1234],endianness: little -
Expect:
value = 4660only if the source bytes were34 12; i.e. withendianness: littlea register transmitted as bytes34 12decodes to0x1234 = 4660.raw = "1234".Rationale vector: bytes
[0x34,0x12]decode aslittle→0x1234=4660, asbig→0x3412=13330.
9.4 uint32, big word order
Section titled “9.4 uint32, big word order”- Registers:
[0x0001, 0x0002] - Point:
{ datatype: uint32, endianness: big, word_order: big, address: { count: 2 } } - Expect:
value = 65538(0x00010002),raw = "0001 0002".
9.5 uint32, little word order
Section titled “9.5 uint32, little word order”- Registers:
[0x0002, 0x0001] - Point:
{ datatype: uint32, word_order: little } - Expect:
value = 65538(first register is least-significant word),raw = "0002 0001".
9.6 int32, signed negative, big word order
Section titled “9.6 int32, signed negative, big word order”- Registers:
[0xffff, 0xfffe] - Point:
{ datatype: int32, word_order: big } - Expect:
value = -2(0xfffffffe),raw = "ffff fffe".
9.7 float32, big-endian, big word order
Section titled “9.7 float32, big-endian, big word order”- Registers:
[0x422a, 0x0000] - Point:
{ datatype: float32, endianness: big, word_order: big } - Expect:
value = 42.5,raw = "422a 0000".
9.8 float32, little word order
Section titled “9.8 float32, little word order”- Registers:
[0x0000, 0x422a] - Point:
{ datatype: float32, word_order: little } - Expect:
value = 42.5(words swapped),raw = "0000 422a".
9.9 float64, big-endian, big word order
Section titled “9.9 float64, big-endian, big word order”- Registers:
[0x4009, 0x21f9, 0xf01b, 0x866e] - Point:
{ datatype: float64, address: { count: 4 } } - Expect:
value = 3.14159,raw = "4009 21f9 f01b 866e".
9.10 bool from a coil
Section titled “9.10 bool from a coil”- Coil read returns
true. - Point:
{ datatype: bool, address: { table: coil, address: 0, count: 1 } } - Expect:
value = true,value_repr = "boolean",raw = "01",quality = "good".
9.11 raw mode holding register
Section titled “9.11 raw mode holding register”- Registers:
[0x1234] - Point:
{ mode: raw, address: { table: holding, address: 0, count: 1 } } - Expect: no
value,raw = "1234",quality = "good".
9.12 Bad read
Section titled “9.12 Bad read”- Modbus exception (e.g. gateway target device failed to respond) for the range.
- Point: any typed point.
- Expect:
quality = "bad",errornon-empty, novalue.
9.13 Bit-field extraction (optional)
Section titled “9.13 Bit-field extraction (optional)”- Register:
[0b0000_0001_1110_0000]=0x01e0 - Point:
{ datatype: uint16, address: { address: 0, count: 1, start_bit: 5, bit_count: 4 } } - Extract bits 5..9 →
0b1111 = 15. - Expect:
value = 15,raw = "01e0".
9.14 Typed write round-trip
Section titled “9.14 Typed write round-trip”- Point:
{ access: read_write, datatype: float32, word_order: big, address: { table: holding, address: 10, count: 2 } } - Command:
{ status: "init", point: "setpoint", value: 42.5, value_repr: "number" } - Expect the connector to write registers
[0x422a, 0x0000]and return{ status: "successful", point: "setpoint", value: 42.5 }.
10. Protocol config schema
Section titled “10. Protocol config schema”A modbus.schema.json MUST validate the protocol-specific objects (connection,
device.protocol_address, point.address). It is published alongside this spec and
referenced by the SDK’s config validation step. Key constraints:
protocol_address.transport∈{tcp, rtu}with conditional required fields per §3.2.point.address.table∈{coil, discrete_input, holding, input}.address.address≥ 0;count≥ 1;start_bit/bit_countonly valid for register tables.- Writable
accessonly permitted forcoilandholdingtables.
Authoring note: the JSON Schema file is intentionally left to implementation, but the constraints above are normative and tested by the conformance suite.
11. Implementation checklist (for an agent)
Section titled “11. Implementation checklist (for an agent)”- Parse and validate
connection/protocol_address/address(§3, §10). - Implement
capabilities()exactly as §2. - Implement
connect()usingtokio-modbusTCP/RTU; reportLinkReportper device. - Implement
read_points()with contiguous-range batching (§5) and SDKdecode_primitive. - Implement
execute()for verbwrite, typed/raw/bit-field (§6). - Emit
badsamples on errors (§5.4, §9.12); never drop reads. - Pass every vector in §9 via the conformance harness.