Files
lmxopcua/docs/research/drivers/modbus-rtu.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

24 KiB
Raw Blame History

Research: Modbus RTU (serial) support

Status: Research / roadmap. Not implemented. Date: 2026-07-15 Author: research sweep Scope: Add Modbus RTU (serial + RTU-over-TCP) to the OtOpcUa server.


TL;DR

Modbus RTU is an added transport mode on the existing ModbusDriver, not a new driver. The existing driver already splits the protocol data unit (PDU: function code + data) from the transport (socket + MBAP framing) behind a clean IModbusTransport seam, and the driver injects transports through a Func<ModbusDriverOptions, IModbusTransport> factory. RTU is a second IModbusTransport implementation that swaps MBAP framing for [address][PDU][CRC-16] framing over a serial line (or a raw TCP socket, for RTU-over-TCP). The register model, function codes, data-type codecs, read planner, coalescing, deadband, write path, and OPC UA materialisation are 100% reused unchanged. Browseable = NO (flat register space, no discovery — identical to Modbus TCP). The pragmatic primary path for a containerised Linux server is RTU-over-TCP to a serial→Ethernet gateway, with direct System.IO.Ports serial as a secondary path for bare-metal / device-mapped deployments. This is very likely the lowest-effort item on the driver roadmap; the only real risk is serial-line timing/behaviour on Linux and in containers.


1. Extend-vs-new-driver verdict — EXTEND

Why the existing code makes this easy

The Modbus driver is already layered exactly the way you'd want in order to add a transport. The seam is IModbusTransport (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);
}

The interface doc comment is explicit that it takes "a PDU (function code + data, excluding the 7-byte MBAP header)" and returns the response PDU — "the transport owns transaction-id pairing, framing, and socket I/O." That is precisely the RTU-vs-TCP boundary. Everything above the seam is transport-neutral:

  • ModbusDriver.cs builds every PDU as a raw byte[] of [functionCode, ...data] — FC01/02/03/04/05/06/15/16 encoders (ReadRegisterBlockAsync, ReadBitBlockAsync, the FC05/06/15/16 write paths, the FC03→bit-swap→FC06 RMW) all call transport.SendAsync(unitId, pdu, ct) and decode the returned PDU. None of them touch MBAP, sockets, or CRC.
  • ModbusTcpTransport.cs is the only place the 7-byte MBAP header, the transaction-id counter (_nextTx), and TcpClient/NetworkStream I/O live. It wraps each PDU as [TxId][Proto=0][Length][UnitId] + PDU, single-flights via a SemaphoreSlim _gate, and does socket-level reconnect/retry.
  • The driver injects the transport: ModbusDriver's constructor takes Func<ModbusDriverOptions, IModbusTransport>? transportFactory, defaulting to o => new ModbusTcpTransport(...). Tests already substitute in-memory fakes through this same hook.

So the entire protocol/codec/planner/health/OPC-UA surface is transport-agnostic today. Adding RTU means adding one class behind the existing seam plus the config plumbing to select it.

The concrete refactor

There is essentially no refactor of existing code needed — the split is already done. The work is additive:

  1. New ModbusRtuTransport : IModbusTransport (serial). Wraps a System.IO.Ports.SerialPort. ConnectAsync opens the port with the configured baud/data-bits/parity/stop-bits. SendAsync frames the ADU as [unitId][PDU][CRC-lo][CRC-hi], enforces the ≥3.5-character inter-frame silence before transmit, writes, then reads the response, strips the address + validates and strips the CRC-16, and returns the bare PDU. Reuses the same single-flight _gate pattern (mandatory on RTU — see §2).
  2. New ModbusRtuOverTcpTransport : IModbusTransport (RTU tunnelled over a socket). Identical RTU framing ([address][PDU][CRC], no MBAP, no TxId) but the byte stream rides a TcpClient/NetworkStream to a serial gateway instead of a COM port. This can share the socket-management, keepalive, idle-disconnect, and reconnect/backoff machinery already in ModbusTcpTransport — the only difference from ModbusTcpTransport is the ADU framing (CRC instead of MBAP) and the absence of a transaction id. Consider extracting the socket lifecycle into a small shared base or helper so both TCP variants share it; the MBAP-vs-CRC framing is the swap point.
  3. A CRC-16 helper (ModbusCrc.Compute(ReadOnlySpan<byte>)) — the standard Modbus CRC with polynomial 0xA001 (reflected 0x8005), CRC appended low-byte-first. Put it in ...Driver.Modbus (or .Addressing).
  4. Transport-mode selection in ModbusDriverOptions + the config DTO + the factory (ModbusDriverFactoryExtensions.CreateInstance) — a Transport discriminator (Tcp | Rtu | RtuOverTcp) plus the serial parameters, wiring the default transportFactory closure to pick the right transport (see §3).
  5. AdminUI: extend ModbusDriverPage.razor with a serial parameters panel shown when Transport != Tcp. No new page — same driver page.

Framing subtlety worth calling out. In Modbus TCP the MBAP Length field tells the transport exactly how many response bytes to read (ModbusTcpTransport.SendOnceAsync reads a 7-byte header, then Length-1 more). RTU has no length field. The RTU transport must determine the response length either (a) by parsing the function code and byte-count field (read responses carry a byte-count; FC05/06/15/16 echoes are fixed-length; an exception response is a fixed 5 bytes with the high bit set on the FC), or (b) by reading until an inter-character idle gap (T1.5/T3.5) elapses. Function-code- aware length calculation is the robust choice and is simplest given the driver already knows the FC set. This is the single genuinely new piece of logic RTU introduces.

Verdict: extend the existing ModbusDriver with two new IModbusTransport implementations + a transport selector. A sibling driver would duplicate the entire codec/planner/health/materialisation surface for zero benefit — the protocol above the wire is identical.


2. Capability mapping — identical to TCP, four deltas

RTU is the same Modbus application protocol as TCP: same register model (Coils / Discrete Inputs / Input Registers / Holding Registers), same function codes (FC0106, 15, 16, and the exception PDU convention), same data types, same read/write semantics. Everything the driver does above the transport seam is unchanged.

Capability Modbus TCP (today) Modbus RTU (added) Delta?
Register model + function codes identical none
Read (FC01/02/03/04) none — same PDU
Write (FC05/06/15/16) none — same PDU
Data-type codecs, byte order, arrays, strings, BCD, bit-in-register none
Read coalescing / auto-prohibit / deadband / WriteOnChangeOnly none
Connectivity probe (FC03@0) none — goes through SendAsync
Transport TCP socket serial line / RTU-over-TCP socket serial vs socket
Framing 7-byte MBAP header + TxId; TCP guarantees integrity [addr][PDU][CRC-16]; app-level CRC CRC-16 vs MBAP
Unit/slave id often 1 (one device per socket); gateway multiplexing exists central — one bus, multiple drop slaves addressed by unit id more prominent
Timing TCP framing; no inter-frame constraint ≥3.5-char inter-frame silence, T1.5 inter-char timing-based framing
Browse/discovery none none none (see §4)
Historian / alarms out of scope out of scope none

The four deltas in detail:

  1. Transport — a SerialPort (or a socket to a gateway) replaces the TcpClient. The socket-reconnect / keepalive / idle-disconnect logic in ModbusTcpTransport is TCP-specific and does not apply to a serial line (a COM port doesn't "drop" the way a NAT'd socket does); the RTU serial transport has its own simpler open/reopen-on-error model. RTU-over-TCP does reuse the socket lifecycle.
  2. Framing — RTU wraps [slaveAddress(1)][PDU][CRC-16-lo][CRC-16-hi]. There is no MBAP header and no transaction id. The CRC-16 (poly 0xA001 reflected, appended low byte first) replaces TCP's transport-level integrity. The transport computes CRC on send and validates on receive, treating a CRC mismatch as a desync/communication error (map onto the existing ModbusTransportDesyncException / BadCommunicationError handling).
  3. Unit-id semantics — on RTU the unit/slave id is the addressing mechanism for a multi-drop bus; a single serial line commonly hosts several slaves. The driver already supports this: ModbusTagDefinition.UnitId is a per-tag override and ResolveUnitId + BuildSlaveHostName already key per-slave resilience by host:port/unitN. Multi-drop RTU "just works" with the existing per-tag UnitId plumbing — the read planner already refuses to coalesce across UnitIds. (For RTU the per-slave "host" key becomes COMx/unitN or gatewayHost:port/unitN.)
  4. Timing — RTU frames are delimited by silence, not length. Requests must be preceded by ≥3.5 character-times of idle; responses are read until the same idle gap (or, preferably, by function-code-aware length). Character time depends on baud/word-length: at 9600 baud, 8-N-1 (10 bits/char), 3.5 chars ≈ 3.6 ms. Above 19200 baud the spec fixes T3.5 at 1.75 ms and T1.5 at 750 µs rather than scaling further. Single-flight is mandatory: RTU has no transaction id to correlate an interleaved response, so at most one transaction may be in flight on a bus — the existing _gate semaphore already provides this.

Read + write are both fully supported, exactly as with Modbus TCP.

Sources for framing/timing/CRC claims: ModbusKit RTU/ASCII/TCP comparison, ModbusSimulator RTU vs TCP, Industrial Monitor Direct — TCP vs RTU-over-TCP.


3. Config JSON shape

Per-tag config is unchanged — the existing ModbusTagDefinition / ModbusTagDto (region, address, data type, byte order, UnitId per-tag override, etc.) already covers everything RTU needs. The additions are driver-level transport fields only.

Proposed additions to the driver config DTO (ModbusDriverConfigDto):

Field Type Applies to Notes
Transport "Tcp" | "Rtu" | "RtuOverTcp" all Discriminator. Default "Tcp" (back-compat).
SerialPort string Rtu COM port / device path, e.g. "COM3" or "/dev/ttyUSB0".
BaudRate int Rtu e.g. 9600, 19200, 38400, 115200.
DataBits int Rtu Usually 8 (RTU).
Parity "None"|"Even"|"Odd" Rtu Modbus spec default Even; many devices use None.
StopBits "One"|"Two" Rtu 1 with parity, 2 without, per spec.
Host / Port string / int Tcp, RtuOverTcp Reused for RtuOverTcp — the serial-gateway's socket.
InterFrameDelayMs int? Rtu Optional override of the computed T3.5 silence for slow/RF links.

Host/Port/UnitId/TimeoutMs/MaxRegistersPerRead/... all stay. Serial fields are ignored when Transport=Tcp; Host/Port are ignored when Transport=Rtu.

Example A — direct serial RTU

{
  "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 shows 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). This is the difference between a gateway operating in "Modbus TCP" translation mode (use Transport: "Tcp") versus "transparent/RTU passthrough" mode (use Transport: "RtuOverTcp").

Note: standard Modbus/TCP masters cannot parse RTU-over-TCP frames and vice-versa — the two are wire-incompatible, so the Transport discriminator must match the gateway's configured mode (Industrial Monitor Direct).


4. BROWSEABILITY VERDICT — NO

Modbus RTU is not browseable — no address-space browser is warranted. This is identical to Modbus TCP. Modbus (any transport) exposes a flat, untyped register space (coils / discrete inputs / input registers / holding registers, addressed 065535) with no discovery protocol — there is no way to enumerate which registers exist, what they mean, or their data types. The mapping from register → engineering meaning lives entirely in the device's vendor documentation, not on the wire.

The existing driver reflects this exactly: ModbusDriverOptions.Tags is documented as "Pre-declared tag map. Modbus has no discovery protocol — the driver returns exactly these," and DiscoverAsync simply materialises the authored tag list into a flat Modbus folder. RediscoverPolicy is Once. RTU changes none of this. The AdminUI's ModbusAddressPickerBody is an address builder (grammar helper for composing a register string), not a live browser — and that stays correct for RTU too.

No browser. Authoring stays manual tag entry / address-builder assisted, same as TCP.


5. Cross-platform serial reality

The server can run on Linux (docker) as well as Windows, so serial-port availability matters.

  • System.IO.Ports.SerialPort is cross-platform on .NET (5+): it ships the built-in implementation for Windows and Linux, distributed as the System.IO.Ports NuGet package (current 10.0.x for .NET 10). On Linux it binds /dev/tty* devices via termios. (NuGet System.IO.Ports, MS Q&A)
  • macOS is the weak platform. Serial support on macOS/MacCatalyst is limited/flaky (baud-rate quirks, MacCatalyst unsupported); developers typically fall back to virtual serial ports for testing. (Mark's Blog — virtual serial ports on macOS, dotnet/runtime #43719). This matters only for the dev machine (this repo's dev is macOS) — production targets are Windows/Linux. RTU unit-testing on macOS should use fakes / RTU-over-TCP, not a real COM port.
  • Containers add a device-mapping hurdle. A serial device must be explicitly passed into the container: docker run --device=/dev/ttyUSB0 (or a compose devices: entry), and USB-serial adapters can re-enumerate (/dev/ttyUSB0ttyUSB1) across reboots/replug, so a stable udev symlink or /dev/serial/by-id/... path is advisable. On Windows containers COM passthrough is notoriously unreliable. (Docker forums — expose host serial port, Portainer device mapping)

Why RTU-over-TCP is the pragmatic primary path for this server. OtOpcUa is deployed as a containerised server (docker-dev rig, Linux docker host at 10.100.0.35) that is generally not physically attached to an RS-485 bus. The idiomatic industrial topology is a serial→Ethernet gateway (Moxa NPort, Digi One, Lantronix, USR-TCP232, etc.) sitting on the RS-485 multidrop and exposing it over TCP. The server then talks RTU-over-TCP to the gateway — a plain socket, zero host-device mapping, no System.IO.Ports dependency on the container, no udev fragility, and it reuses the already-hardened socket lifecycle (keepalive / idle-disconnect / reconnect-backoff) from ModbusTcpTransport. Direct System.IO.Ports serial should ship too (for bare-metal Windows/Linux installs with a local COM port or device-mapped adapter), but RTU-over-TCP is the path most deployments will actually use, and it's the lower-risk one to build and test.


6. Test-fixture strategy

Three complementary options, in rough order of value for this repo:

  1. RTU-over-TCP against pymodbus (highest ROI, no serial hardware). The existing Modbus fixture already runs pymodbus.simulator in docker (tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/, binding :5020). pymodbus can serve an RTU-framed TCP server, so an rtu_over_tcp profile alongside the existing standard/dl205/mitsubishi/ exception_injection profiles exercises the real RTU framing + CRC path end-to-end with no serial anything — same harness, same docker host, same lmxopcua-fix up modbus <profile> workflow. This validates the CRC codec and the function-code-aware framing, which is the only genuinely new logic.

  2. Virtual serial pair on Linux 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.) This is the only way to cover the real System.IO.Ports open/read/write path without hardware, and it runs in a Linux container or on the docker host. macOS dev can't easily do this — run it on the Linux docker host or in CI.

  3. A dedicated RTU slave simulatordiagslave (serial + TCP RTU modes) or ModbusPal — for manual / soak testing against a virtual pair or a real USB-serial adapter. Useful for the eventual live-gate but not for unit CI.

Unit-level: the CRC-16 helper gets a table-driven unit test against known Modbus CRC vectors, and ModbusRtuTransport/ModbusRtuOverTcpTransport can be tested with an in-memory duplex stream fake (the driver already fakes IModbusTransport; here we fake one level lower, the byte stream, to assert framing + CRC + response parsing). No PLC needed for the bulk of coverage.

Recommended CI shape: unit tests for CRC + RTU framing (fake stream) + an rtu_over_tcp pymodbus docker profile for integration; defer real-serial (socat pair / hardware) to an env-gated live suite like the other driver live gates.


7. Effort / risk

Effort: LOW — likely the lowest-effort item on the driver roadmap. Because the PDU layer is already transport-agnostic and injected, the net-new code is small and localised:

  • ModbusCrc helper (~30 lines) + unit test.
  • ModbusRtuOverTcpTransport — can largely reuse ModbusTcpTransport's socket lifecycle; the delta is CRC framing + FC-aware response length (no TxId). Extracting the shared socket lifecycle into a base/helper is the main refactor, and it's mechanical.
  • ModbusRtuTransport (serial) — SerialPort open + the same framing + T3.5 timing.
  • Config: Transport discriminator + serial fields on ModbusDriverOptions, the DTO, and the factory closure (~1 file each).
  • AdminUI: a serial-parameters panel on the existing ModbusDriverPage.razor, shown when Transport != Tcp, + the matching config-model round-trip. Watch the known enum-serialization trap (per project memory: driver pages serialize enums numerically but factory DTOs are string-typed — add JsonStringEnumConverter so Transport/Parity/StopBits round-trip as strings, mirroring OpcUaClient).
  • Zero changes to codecs, planner, coalescing, health, materialisation, HistoryRead, or the address parser.

Risks (all manageable):

  • RTU response framing without a length field is the one novel piece of logic — get the function-code-aware length calculation (and exception-PDU short-frame detection) right, or fall back to idle-gap timeout. Cover with the fake-stream unit tests.
  • Serial timing on Linux / in containersSystem.IO.Ports on Linux honours read timeouts but fine-grained T1.5/T3.5 inter-character gating is best-effort; slow or long RS-485/RF runs may need the InterFrameDelayMs override. This is the top residual risk and the reason to lead with RTU-over-TCP.
  • macOS dev can't exercise real serial — mitigated by making RTU-over-TCP the primary tested path and running the socat/serial suite on the Linux docker host / CI, not the dev Mac.
  • USB-serial device enumeration in containers (/dev/ttyUSB* renumbering) — a deployment/ops concern, addressed with --device + stable by-id paths, not a code risk.

Bottom line: small, additive, low-risk. Ship RTU-over-TCP first (reuses the hardened socket path, no host-device dependency, testable on the existing pymodbus docker harness), then direct System.IO.Ports serial for bare-metal installs.


Key source files (for the implementer)

  • src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs — the seam RTU plugs into.
  • src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs — the reference transport; RTU-over-TCP reuses its socket lifecycle.
  • src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs — transport-agnostic PDU builders + factory injection point (transportFactory).
  • src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs + ModbusEquipmentTagParser.cs — where the transport-mode + serial options are added.
  • src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs — DTO + transport selection.
  • src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor — driver config UI to extend.
  • tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/ — pymodbus fixture to add an rtu_over_tcp profile to.

Sources