Files
lmxopcua/docs/plans/2026-07-15-modbus-rtu-driver-design.md
T
Joseph Doherty 8fc147d8d4
v2-ci / build (push) Successful in 3m14s
v2-ci / unit-tests (push) Failing after 9m13s
docs: driver-expansion program — 8 research reports + 7 design docs, parallel-reviewed
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
2026-07-15 16:40:36 -04:00

21 KiB
Raw Blame History

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 FC0106/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
}
  • ModbusDriver builds every request as a raw PDU byte[] and calls transport.SendAsync(ResolveUnitId(tag), pdu, ct) (e.g. ModbusDriver.cs lines ~1014/1035/1052/1069/1142/1154). It never touches MBAP, sockets, or CRC.
  • ModbusTcpTransport is the only place MBAP framing, the transaction-id counter (_nextTx), and TcpClient/NetworkStream I/O live — specifically SendOnceAsync (ModbusTcpTransport.cs lines 231296) wraps the PDU as [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU, single-flights via SemaphoreSlim _gate, and owns reconnect/keepalive/idle-disconnect.
  • The transport is injected: ModbusDriver's ctor takes Func<ModbusDriverOptions, IModbusTransport>? transportFactory (ModbusDriver.cs lines 98114); the default closure builds ModbusTcpTransport. 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 newSystem.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 newCreate(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). Read addr(1) + fc(1) first, then:
    • Exception (fc & 0x80): read excCode(1) + CRC(2) → 5-byte ADU total; throw ModbusException(fc & 0x7F, excCode, …) after CRC-validating.
    • Read responses FC01/02/03/04: read byteCount(1), then byteCount + CRC(2).
    • Write echoes FC05/06/15/16: fixed addr + qty/value(4) + CRC(2) → read 6 more.
  • After the full ADU is in hand: validate the trailing CRC-16 (mismatch → ModbusTransportDesyncException(DesyncReason.…) so it maps onto the existing BadCommunicationError handling), strip the address byte + CRC, and return the bare response PDU [fc, ...data] — exactly what SendAsync's callers already decode.

2b. ModbusRtuTransport : IModbusTransport (direct serial)

  • Wraps a System.IO.Ports.SerialPort. ConnectAsync opens the port with the configured PortName/BaudRate/DataBits/Parity/StopBits; sets ReadTimeout/WriteTimeout from ModbusDriverOptions.Timeout.
  • Per-op deadline (R2-01): SerialPort.ReadTimeout only governs the synchronous read API — async reads over SerialPort.BaseStream ignore ReadTimeout/WriteTimeout entirely (the exact wall-clock gap class R2-01 found in the S7 driver, where async socket reads ignored ReadTimeout and a frozen peer wedged the poll). Every transaction MUST therefore run under a linked-CTS CancellationTokenSource.CancelAfter(Options.Timeout) deadline, mirroring ModbusTcpTransport.SendOnceAsync — timeout ⇒ close the port + surface ModbusTransportDesyncException(DesyncReason.Timeout), distinct from caller cancellation (no teardown).
  • SendAsync: single-flight via the same SemaphoreSlim _gate pattern (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 the InterFrameDelayMs override), write the ADU, then read the response using ModbusRtuFraming'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 a TcpClient/NetworkStream to 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 from ModbusTcpTransport into ModbusSocketLifecycle and have both TCP variants compose it. The only delta from ModbusTcpTransport is: 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.SerialPort is cross-platform on .NET 10 (System.IO.Ports 10.0.x): Windows + Linux (/dev/tty* via termios). macOS is the weak platform (baud quirks, MacCatalyst unsupported) — 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/ttyUSB0 or a compose devices: entry), and USB-serial adapters re-enumerate (ttyUSB0ttyUSB1) 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/StopBits names collide with System.IO.Ports.Parity/StopBits (member names match the table), but define local enums in Driver.Modbus.Contracts and map them to the BCL enums inside ModbusRtuTransport. Reusing the BCL enums directly would put the System.IO.Ports package on the Contracts project (and transitively on the AdminUI, which deserializes ModbusDriverOptions) — contradicting §1's plan to add the dependency to Driver.Modbus only, 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 328334) already has Converters = { new JsonStringEnumConverter() } + camelCase. It serializes a ModbusDriverOptions directly, so a ModbusTransportMode/Parity/StopBits on ModbusDriverOptions emits as "Rtu"/"Even"/"One" automatically.
  • ModbusDriverFactoryExtensions DTO fields are typed string? and parsed via the existing ParseEnum<T> helper (case-insensitive) — mirror how Family / MelsecSubFamily are already handled. Do not type the DTO fields as the enum.
  • ModbusDriverProbe._opts (line 1924) already carries JsonStringEnumConverter.

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 — not SerialPort.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). InterFrameDelayMs overrides 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 ModbusTcpTransport via the extracted ModbusSocketLifecycle. 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 _gate semaphore already provides it; keep it in both new transports.

8. Test fixtures

  1. RTU-over-TCP against pymodbus (highest ROI, no serial hardware). Add an rtu_over_tcp profile to the existing fixture tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml (alongside standard/dl205/mitsubishi/s7_1500/exception_injection), running pymodbus.simulator in RTU-framed-TCP mode. The existing profiles all share host port :5020 and are mutually exclusive by design; bind rtu_over_tcp to a fresh port so it can co-run with standard (and sidestep the known shared-port stale-container trap). Add the project=lmxopcua label per the program-doc fixture convention (the checked-in compose services carry no labels: entries today). Drive it via lmxopcua-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.
  2. Virtual serial pair for the direct-serial transport. socat -d -d pty,raw,echo=0 pty,raw,echo=0 creates a linked /dev/pts/N/dev/pts/M pair; point a pymodbus RTU serial slave at one end and ModbusRtuTransport at the other. (com0com is 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).
  3. 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):

  • ModbusCrc table-driven test against known Modbus CRC vectors.
  • ModbusRtuFraming / ModbusRtuTransport / ModbusRtuOverTcpTransport against an in-memory duplex stream fake (fake one level below the existing IModbusTransport fakes — 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 JsonStringEnumConverter guard (§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 ModbusSocketLifecycle from ModbusTcpTransport (mechanical refactor; ModbusTcpTransport keeps behaviour) + ModbusTransportFactory.Create + config fields on ModbusDriverOptions/DTO + factory closure + probe wiring. Unit tests for CRC + framing + config round-trip.
  • P1 — ModbusRtuOverTcpTransport (reuses the socket lifecycle; delta = CRC framing + FC-aware length, no TxId) + rtu_over_tcp pymodbus 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.Ports PackageReference + 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.