Adds the driver-expansion program design (umbrella: universal Discover-backed browser + MTConnect, MQTT/Sparkplug B, BACnet/IP, SQL poll, Omron, Modbus RTU; MELSEC deferred) plus the per-driver research reports. All docs went through a 7-agent parallel review against the codebase before this commit. Highlights fixed in review: - universal browser: FOCAS FixedTree fills post-connect -> UntilStable settle + FixedTree.Enabled patch; MQTT reconciled to bespoke (was contradicting the program doc's SupportsOnlineDiscovery=false verdict) - modbus-rtu: SerialPort.ReadTimeout doesn't bound async BaseStream reads -> linked-CTS per-op deadline (R2-01 class); BCL enum reuse would leak System.IO.Ports into Contracts - bacnet: DiscoveryRediscoverPolicy enum name; UDP 47808 contention; live suite rewritten around unicast Who-Is + BBMD (broadcast doesn't cross VMs) - sql-poll: real tier registration via DriverFactoryRegistry.Register; blackhole gate must not docker-pause the shared central SQL Server - mqtt: Sparkplug v3.0 STATE topic form; first-in-repo proto codegen noted - omron: host hardcodes isIdempotent:false today (retry seam unshipped); v1 scopes UDTs to dotted-leaf access - mtconnect: SecurityClassification.ViewOnly; factory ParseEnum<T> pattern - program doc: both valid enum-serialization patterns; IRediscoverable is change-signal-gated; RTU P2 adds System.IO.Ports; label is host-side
21 KiB
Modbus RTU (serial + RTU-over-TCP) — implementation design
Status: Design / build-ready. Not implemented.
Date: 2026-07-15
Scope: Add Modbus RTU transport modes (direct serial + RTU-over-TCP) to the
existing ModbusDriver.
Research input: docs/research/drivers/modbus-rtu.md.
Related: docs/drivers/Modbus.md,
docs/v2/modbus-addressing.md,
docs/plans/2026-07-15-universal-discovery-browser-design.md.
1. Motivation + extend-vs-new verdict — EXTEND
Add Modbus RTU as two new IModbusTransport implementations behind the existing
transport seam + driver-level config plumbing to select them. Do not create a
sibling driver. The Modbus application protocol above the wire (register model, function
codes FC01–06/15/16, exception-PDU convention, data-type codecs, byte order, arrays,
strings, BCD, bit-in-register, read planner + coalescing, auto-prohibit, deadband,
write path, connectivity probe, OPC-UA materialisation, HistoryRead) is byte-for-byte
identical across TCP and RTU — only the wire framing and the physical link differ. A
second driver would clone that entire surface for zero benefit.
The exact seam this plugs into
The driver already splits the PDU (function code + data) from the transport (framing + link I/O) behind one interface:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs
public interface IModbusTransport : IAsyncDisposable
{
Task ConnectAsync(CancellationToken ct);
Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct); // pdu = [fc, ...data], returns response PDU
}
ModbusDriverbuilds every request as a raw PDUbyte[]and callstransport.SendAsync(ResolveUnitId(tag), pdu, ct)(e.g.ModbusDriver.cslines ~1014/1035/1052/1069/1142/1154). It never touches MBAP, sockets, or CRC.ModbusTcpTransportis the only place MBAP framing, the transaction-id counter (_nextTx), andTcpClient/NetworkStreamI/O live — specificallySendOnceAsync(ModbusTcpTransport.cslines 231–296) wraps the PDU as[TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU, single-flights viaSemaphoreSlim _gate, and owns reconnect/keepalive/idle-disconnect.- The transport is injected:
ModbusDriver's ctor takesFunc<ModbusDriverOptions, IModbusTransport>? transportFactory(ModbusDriver.cslines 98–114); the default closure buildsModbusTcpTransport. Tests already substitute in-memory fakes through this hook.
So the work is purely additive: two new transport classes + a CRC-16 helper + config/factory selection + one AdminUI panel. Zero changes to codecs, planner, coalescing, health, materialisation, HistoryRead, or the address parser.
New/changed files at a glance
| File | Change |
|---|---|
…/Driver.Modbus/ModbusCrc.cs |
new — CRC-16 (poly 0xA001) helper |
…/Driver.Modbus/ModbusRtuFraming.cs |
new — shared ADU frame/deframe + FC-aware response sizing |
…/Driver.Modbus/ModbusRtuTransport.cs |
new — System.IO.Ports.SerialPort transport |
…/Driver.Modbus/ModbusRtuOverTcpTransport.cs |
new — RTU framing over a socket |
…/Driver.Modbus/ModbusSocketLifecycle.cs |
new (refactor) — socket connect/reconnect/keepalive/idle extracted from ModbusTcpTransport, shared by TCP + RtuOverTcp |
…/Driver.Modbus/ModbusTransportFactory.cs |
new — Create(ModbusDriverOptions) switch on Transport; used by the driver default closure and the probe |
…/Driver.Modbus.Contracts/ModbusDriverOptions.cs |
add Transport + serial fields (+ two enums) |
…/Driver.Modbus/ModbusDriverFactoryExtensions.cs |
DTO fields + wire the default closure to ModbusTransportFactory.Create |
…/Driver.Modbus/ModbusDriverProbe.cs |
build the transport via ModbusTransportFactory (currently hardcodes new ModbusTcpTransport, line 77) |
…/Driver.Modbus/ModbusDriver.cs |
BuildSlaveHostName endpoint string reflects the transport (COM/gateway vs host:port) |
…/AdminUI/…/Drivers/ModbusDriverPage.razor |
serial-parameter panel shown when Transport != Tcp |
System.IO.Ports (10.0.x for .NET 10) becomes a new PackageReference on
ZB.MOM.WW.OtOpcUa.Driver.Modbus.
2. Transport implementations
Both new transports produce the RTU ADU: [slaveAddress(1)][PDU][CRC-lo][CRC-hi] —
no MBAP header, no transaction id. CRC-16 (poly 0xA001 reflected, appended
low byte first) replaces TCP's transport-level integrity. The RTU framing/deframing
logic is identical between the two; factor it into ModbusRtuFraming so the serial and
socket transports share it and differ only in the byte-stream they read/write.
2a. ModbusRtuFraming (shared)
byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)→[unit][pdu][crcLo][crcHi].int ExpectedResponseLength(byte functionCode)/ a streaming reader that determines response length by parsing the function code (RTU frames are length-less — see §7). Readaddr(1)+fc(1)first, then:- Exception (
fc & 0x80): readexcCode(1) + CRC(2)→ 5-byte ADU total; throwModbusException(fc & 0x7F, excCode, …)after CRC-validating. - Read responses FC01/02/03/04: read
byteCount(1), thenbyteCount + CRC(2). - Write echoes FC05/06/15/16: fixed
addr + qty/value(4) + CRC(2)→ read 6 more.
- Exception (
- After the full ADU is in hand: validate the trailing CRC-16 (mismatch →
ModbusTransportDesyncException(DesyncReason.…)so it maps onto the existingBadCommunicationErrorhandling), strip the address byte + CRC, and return the bare response PDU[fc, ...data]— exactly whatSendAsync's callers already decode.
2b. ModbusRtuTransport : IModbusTransport (direct serial)
- Wraps a
System.IO.Ports.SerialPort.ConnectAsyncopens the port with the configuredPortName/BaudRate/DataBits/Parity/StopBits; setsReadTimeout/WriteTimeoutfromModbusDriverOptions.Timeout. - Per-op deadline (R2-01):
SerialPort.ReadTimeoutonly governs the synchronous read API — async reads overSerialPort.BaseStreamignoreReadTimeout/WriteTimeoutentirely (the exact wall-clock gap class R2-01 found in the S7 driver, where async socket reads ignoredReadTimeoutand a frozen peer wedged the poll). Every transaction MUST therefore run under a linked-CTSCancellationTokenSource.CancelAfter(Options.Timeout)deadline, mirroringModbusTcpTransport.SendOnceAsync— timeout ⇒ close the port + surfaceModbusTransportDesyncException(DesyncReason.Timeout), distinct from caller cancellation (no teardown). SendAsync: single-flight via the sameSemaphoreSlim _gatepattern (mandatory on RTU — no TxId means at most one transaction on the bus at a time). Enforce ≥3.5-char inter-frame idle before transmit (computed from baud/word-length, or theInterFrameDelayMsoverride), write the ADU, then read the response usingModbusRtuFraming's FC-aware sizing, backstopped by the linked-CTS per-op deadline.- Failure model is simpler than TCP — a COM port doesn't "drop" the way a NAT'd
socket does. On an I/O error or CRC desync, close + reopen the port once and retry
(reuse the single-retry shape from
ModbusTcpTransport.SendAsync); no keepalive, no idle-disconnect, no geometric socket backoff.
2c. ModbusRtuOverTcpTransport : IModbusTransport (RTU tunnelled over a socket)
- Identical RTU framing (
ModbusRtuFraming) but the byte stream rides aTcpClient/NetworkStreamto a serial→Ethernet gateway instead of a COM port. - Reuses the hardened socket lifecycle — extract the connect (IPv4-preference DNS),
SO_KEEPALIVE, idle-disconnect, and reconnect-with-backoff machinery fromModbusTcpTransportintoModbusSocketLifecycleand have both TCP variants compose it. The only delta fromModbusTcpTransportis: CRC framing instead of MBAP, and no transaction id. This is the lowest-risk of the two RTU transports to build and test (see §9).
Modbus ASCII is out of scope — a third, rarely-used framing (:-delimited hex,
LRC instead of CRC). It would be another IModbusTransport behind the same seam if ever
needed; not planned.
3. Cross-platform serial reality
System.IO.Ports.SerialPortis cross-platform on .NET 10 (System.IO.Ports10.0.x): Windows + Linux (/dev/tty*via termios). macOS is the weak platform (baud quirks,MacCatalystunsupported) — this repo's dev machine is macOS, so direct-serial cannot be exercised on the dev Mac; use fakes / RTU-over-TCP there and run the real-serial suite on the Linux docker host / CI (§8).- Containers add a device-mapping hurdle: a serial device must be explicitly passed
in (
docker run --device=/dev/ttyUSB0or a composedevices:entry), and USB-serial adapters re-enumerate (ttyUSB0↔ttyUSB1) across replug — pin a stable/dev/serial/by-id/...path. Windows-container COM passthrough is unreliable.
RTU-over-TCP is the recommended primary path. OtOpcUa deploys as a containerised
Linux server (docker-dev rig; docker host 10.100.0.35) that is generally not
attached to an RS-485 bus. The idiomatic topology is a serial→Ethernet gateway (Moxa
NPort, Digi One, Lantronix, USR-TCP232) on the RS-485 multidrop, exposed over TCP. The
server talks RTU-over-TCP to it — a plain socket, zero host-device mapping, no
System.IO.Ports dependency at runtime, no udev fragility, and it reuses the already-
hardened socket lifecycle. Direct serial ships too, for bare-metal Windows/Linux installs
with a local/adapter COM port, but RTU-over-TCP is what most deployments will use.
4. Config JSON shape
Per-tag TagConfig is unchanged. ModbusTagDefinition / ModbusTagDto already carry
everything RTU needs — including the per-tag UnitId override that makes an RS-485
multi-drop bus "just work" (the read planner already refuses to coalesce across UnitIds;
ResolveUnitId + BuildSlaveHostName already key per-slave resilience by unit). The
ModbusTagConfigEditor in TagConfigEditorMap needs no change. Additions are
driver-level only.
New fields on ModbusDriverOptions (and the matching optional fields on
ModbusDriverConfigDto):
| Field | Type | Applies to | Notes |
|---|---|---|---|
Transport |
ModbusTransportMode enum (Tcp|Rtu|RtuOverTcp) |
all | Default Tcp (back-compat: existing configs omit it) |
SerialPort |
string | Rtu | "COM3" / "/dev/ttyUSB0" / by-id path |
BaudRate |
int | Rtu | 9600 / 19200 / 38400 / 115200 |
DataBits |
int | Rtu | usually 8 |
Parity |
Parity enum (None|Even|Odd) |
Rtu | spec default Even; many devices use None |
StopBits |
StopBits enum (One|Two) |
Rtu | 1 with parity, 2 without |
InterFrameDelayMs |
int? | Rtu | optional override of computed T3.5 for slow/RF links |
Host / Port |
reused | Tcp, RtuOverTcp | the gateway's socket for RtuOverTcp |
Host/Port/UnitId/TimeoutMs/MaxRegistersPerRead/keepalive/reconnect all stay.
Serial fields are ignored when Transport=Tcp; Host/Port are ignored when
Transport=Rtu. Keepalive/idle/reconnect apply to Tcp + RtuOverTcp only.
Serialization note:
Parity/StopBitsnames collide withSystem.IO.Ports.Parity/StopBits(member names match the table), but define local enums inDriver.Modbus.Contractsand map them to the BCL enums insideModbusRtuTransport. Reusing the BCL enums directly would put theSystem.IO.Portspackage on the Contracts project (and transitively on the AdminUI, which deserializesModbusDriverOptions) — contradicting §1's plan to add the dependency toDriver.Modbusonly, and the program-doc rule that Contracts carries no backend NuGet dep.
Example A — direct serial RTU (multi-drop)
{
"transport": "Rtu",
"serialPort": "/dev/ttyUSB0",
"baudRate": 19200,
"dataBits": 8,
"parity": "Even",
"stopBits": "One",
"unitId": 1,
"timeoutMs": 1000,
"tags": [
{ "name": "Flow", "addressString": "40001:F:ABCD", "writable": false },
{ "name": "Setpt", "addressString": "40010:F", "writable": true },
{ "name": "Pump2Run", "region": "Coils", "address": 0, "dataType": "Bool",
"writable": true, "unitId": 2 }
]
}
Pump2Run is a second drop slave (UnitId 2) on the same bus — no extra transport config,
just the per-tag unitId override the driver already honours.
Example B — RTU-over-TCP to a serial→Ethernet gateway
{
"transport": "RtuOverTcp",
"host": "10.20.0.50",
"port": 4001,
"unitId": 1,
"timeoutMs": 1500,
"tags": [
{ "name": "Temp", "addressString": "30001:I" },
{ "name": "Alarm", "region": "DiscreteInputs", "address": 5, "dataType": "Bool" }
]
}
Same host/port shape as Modbus TCP, but the wire frames are raw RTU (address + CRC,
no MBAP). The Transport discriminator must match the gateway's configured mode:
"Tcp" for a gateway doing Modbus/TCP translation, "RtuOverTcp" for
transparent/RTU-passthrough. The two wire formats are mutually unparseable.
5. The JsonStringEnumConverter trap — must handle
Per the project-wide enum-serialization bug (memory: Driver enum-serialization bug),
driver pages serialize enums but factory DTOs are string-typed. The new
Transport/Parity/StopBits enums must round-trip as strings, or an AdminUI-
authored RTU config faults the driver at deploy.
Good news — the plumbing is already correct on both ends and just needs the new fields:
ModbusDriverPage.razor's serializer (_jsonOpts, line 328–334) already hasConverters = { new JsonStringEnumConverter() }+ camelCase. It serializes aModbusDriverOptionsdirectly, so aModbusTransportMode/Parity/StopBitsonModbusDriverOptionsemits as"Rtu"/"Even"/"One"automatically.ModbusDriverFactoryExtensionsDTO fields are typedstring?and parsed via the existingParseEnum<T>helper (case-insensitive) — mirror howFamily/MelsecSubFamilyare already handled. Do not type the DTO fields as the enum.ModbusDriverProbe._opts(line 19–24) already carriesJsonStringEnumConverter.
Add a driver-page/factory round-trip unit test asserting "transport":"Rtu" (string, not
1) — the same guard that caught S7/Modbus previously.
6. Capability mapping — identical to TCP, four deltas
Same register model, function codes, data types, read/write semantics, coalescing,
deadband, WriteOnChangeOnly, connectivity probe (FC03@0). Read + write both fully
supported. The only deltas are below the seam:
| Delta | TCP (today) | RTU (added) |
|---|---|---|
| Physical link | TcpClient |
SerialPort (Rtu) / TcpClient (RtuOverTcp) |
| Framing | 7-byte MBAP + TxId | [addr][PDU][CRC-16], no TxId |
| Integrity | TCP guarantees | app-level CRC-16 (0xA001) |
| Unit/slave id | often 1 | central — one bus, many drops by unit id (already supported per-tag) |
| Response length | MBAP Length field |
no length field → FC-aware sizing (§7) |
| Timing | none | ≥3.5-char inter-frame silence; single-flight mandatory |
ResolveHost / BuildSlaveHostName (ModbusDriver.cs line 162,
"{host}:{port}/unit{n}") should format the per-slave resilience key from the active
endpoint — COMx/unit{n} (Rtu) or gatewayHost:port/unit{n} (RtuOverTcp) — so
per-slave breakers stay distinct.
Browseability — NO (reconcile w/ universal browser)
Modbus RTU is not browsable — a flat, untyped register space with no discovery
protocol, identical to Modbus TCP. SupportsOnlineDiscovery=false; the driver keeps
RediscoverPolicy = Once and materialises the authored tag list into a flat folder. No
browser (universal or bespoke). The AdminUI ModbusAddressPickerBody/ModbusAddressBuilder
is an address builder (grammar helper), not a live browser, and stays valid for RTU.
7. Resilience / timeout
- Per-op deadline + T3.5 framing. The linked-CTS
CancelAfter(Options.Timeout)per-op deadline (§2b — notSerialPort.ReadTimeout, which async reads ignore) is the hard backstop; FC-aware sizing is the primary length signal, with the inter-frame idle gap as the delimiter fallback. Above 19200 baud the spec fixes T3.5≈1.75 ms / T1.5≈750 µs rather than scaling further; below it, T3.5 ≈ 3.5 × (bits-per-char / baud) (e.g. 9600 8-N-1 ≈ 3.6 ms).InterFrameDelayMsoverrides for slow/long RS-485 or RF links. - The one genuinely new correctness risk: RTU response sizing without a length field.
Get the FC-aware calculation + exception-PDU short-frame detection right (§2a), or the
read hangs to timeout / mis-frames. Cover exhaustively with fake-stream unit tests
(below). A CRC mismatch or truncated frame maps to
ModbusTransportDesyncException→ the existing single reconnect-retry + status mapping. - RtuOverTcp reuses the socket lifecycle — keepalive, idle-disconnect, reconnect
backoff, IPv4-preference connect — unchanged from
ModbusTcpTransportvia the extractedModbusSocketLifecycle. Direct serial does not use any of that (a COM port has no NAT reaping); it uses the simpler close/reopen-once-on-error model. - Single-flight is mandatory on RTU (no TxId to correlate an interleaved response) —
the existing
_gatesemaphore already provides it; keep it in both new transports.
8. Test fixtures
- RTU-over-TCP against pymodbus (highest ROI, no serial hardware). Add an
rtu_over_tcpprofile to the existing fixturetests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml(alongsidestandard/dl205/mitsubishi/s7_1500/exception_injection), runningpymodbus.simulatorin RTU-framed-TCP mode. The existing profiles all share host port:5020and are mutually exclusive by design; bindrtu_over_tcpto a fresh port so it can co-run withstandard(and sidestep the known shared-port stale-container trap). Add theproject=lmxopcualabel per the program-doc fixture convention (the checked-in compose services carry nolabels:entries today). Drive it vialmxopcua-fix up modbus rtu_over_tcp+lmxopcua-fix sync modbus. This exercises the real CRC + FC-aware framing end-to-end with no serial anything — the only genuinely new logic. - Virtual serial pair for the direct-serial transport.
socat -d -d pty,raw,echo=0 pty,raw,echo=0creates a linked/dev/pts/N↔/dev/pts/Mpair; point a pymodbus RTU serial slave at one end andModbusRtuTransportat the other. (com0comis the Windows equivalent.) Runs on the Linux docker host / CI, not the dev Mac. Defer to an env-gated live suite (like the other driver live gates). diagslave(serial + TCP RTU) for manual/soak against a virtual pair or a real USB-serial adapter — eventual live gate, not unit CI.
Unit-level (no PLC):
ModbusCrctable-driven test against known Modbus CRC vectors.ModbusRtuFraming/ModbusRtuTransport/ModbusRtuOverTcpTransportagainst an in-memory duplex stream fake (fake one level below the existingIModbusTransportfakes — the byte stream) asserting ADU build, CRC append/validate, FC-aware response parse for each FC group, and exception-PDU short-frame handling.- Driver-page/factory round-trip test for the
JsonStringEnumConverterguard (§5).
Recommended CI shape: unit (CRC + framing + config round-trip) + the rtu_over_tcp
pymodbus profile for integration; real-serial (socat / hardware) env-gated live.
9. Phasing + effort
Effort: LOW — likely the lowest-effort item on the driver roadmap. The seam is purpose-built; net-new code is small and localised.
- P0 — shared plumbing:
ModbusCrc(~30 lines) +ModbusRtuFraming(FC-aware sizing)- extract
ModbusSocketLifecyclefromModbusTcpTransport(mechanical refactor;ModbusTcpTransportkeeps behaviour) +ModbusTransportFactory.Create+ config fields onModbusDriverOptions/DTO + factory closure + probe wiring. Unit tests for CRC + framing + config round-trip.
- extract
- P1 —
ModbusRtuOverTcpTransport(reuses the socket lifecycle; delta = CRC framing + FC-aware length, no TxId) +rtu_over_tcppymodbus docker profile + AdminUI serial panel wired for the RtuOverTcp subset. Ship this first — no host-device dependency, testable on the existing pymodbus harness, reuses hardened socket code. - P2 —
ModbusRtuTransport(direct serial) +System.IO.PortsPackageReference + socat/diagslave env-gated live suite. For bare-metal installs.
Top risk: serial framing/timing on Linux/in-containers (best-effort T1.5/T3.5 gating;
device enumeration; the length-less-frame response-sizing correctness). Mitigated by
leading with RTU-over-TCP (P1), exhaustive fake-stream framing tests, and the
InterFrameDelayMs override for slow links. Direct-serial (P2) is the residual-risk
piece and is deferred behind the env-gated live suite.