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
24 KiB
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.csbuilds every PDU as a rawbyte[]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 calltransport.SendAsync(unitId, pdu, ct)and decode the returned PDU. None of them touch MBAP, sockets, or CRC.ModbusTcpTransport.csis the only place the 7-byte MBAP header, the transaction-id counter (_nextTx), andTcpClient/NetworkStreamI/O live. It wraps each PDU as[TxId][Proto=0][Length][UnitId] + PDU, single-flights via aSemaphoreSlim _gate, and does socket-level reconnect/retry.- The driver injects the transport:
ModbusDriver's constructor takesFunc<ModbusDriverOptions, IModbusTransport>? transportFactory, defaulting too => 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:
- New
ModbusRtuTransport : IModbusTransport(serial). Wraps aSystem.IO.Ports.SerialPort.ConnectAsyncopens the port with the configured baud/data-bits/parity/stop-bits.SendAsyncframes 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_gatepattern (mandatory on RTU — see §2). - New
ModbusRtuOverTcpTransport : IModbusTransport(RTU tunnelled over a socket). Identical RTU framing ([address][PDU][CRC], no MBAP, no TxId) but the byte stream rides aTcpClient/NetworkStreamto a serial gateway instead of a COM port. This can share the socket-management, keepalive, idle-disconnect, and reconnect/backoff machinery already inModbusTcpTransport— the only difference fromModbusTcpTransportis 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. - A CRC-16 helper (
ModbusCrc.Compute(ReadOnlySpan<byte>)) — the standard Modbus CRC with polynomial0xA001(reflected0x8005), CRC appended low-byte-first. Put it in...Driver.Modbus(or.Addressing). - Transport-mode selection in
ModbusDriverOptions+ the config DTO + the factory (ModbusDriverFactoryExtensions.CreateInstance) — aTransportdiscriminator (Tcp|Rtu|RtuOverTcp) plus the serial parameters, wiring the defaulttransportFactoryclosure to pick the right transport (see §3). - AdminUI: extend
ModbusDriverPage.razorwith a serial parameters panel shown whenTransport != 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 (FC01–06, 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:
- Transport — a
SerialPort(or a socket to a gateway) replaces theTcpClient. The socket-reconnect / keepalive / idle-disconnect logic inModbusTcpTransportis 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. - Framing — RTU wraps
[slaveAddress(1)][PDU][CRC-16-lo][CRC-16-hi]. There is no MBAP header and no transaction id. The CRC-16 (poly0xA001reflected, 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 existingModbusTransportDesyncException/BadCommunicationErrorhandling). - 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.UnitIdis a per-tag override andResolveUnitId+BuildSlaveHostNamealready key per-slave resilience byhost: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 becomesCOMx/unitNorgatewayHost:port/unitN.) - 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
_gatesemaphore 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 0–65535) 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.SerialPortis cross-platform on .NET (5+): it ships the built-in implementation for Windows and Linux, distributed as theSystem.IO.PortsNuGet package (current10.0.xfor .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,
MacCatalystunsupported); 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 composedevices:entry), and USB-serial adapters can re-enumerate (/dev/ttyUSB0↔ttyUSB1) across reboots/replug, so a stableudevsymlink 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:
-
RTU-over-TCP against pymodbus (highest ROI, no serial hardware). The existing Modbus fixture already runs
pymodbus.simulatorin docker (tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/, binding:5020). pymodbus can serve an RTU-framed TCP server, so anrtu_over_tcpprofile alongside the existingstandard/dl205/mitsubishi/exception_injectionprofiles exercises the real RTU framing + CRC path end-to-end with no serial anything — same harness, same docker host, samelmxopcua-fix up modbus <profile>workflow. This validates the CRC codec and the function-code-aware framing, which is the only genuinely new logic. -
Virtual serial pair on Linux 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.) This is the only way to cover the realSystem.IO.Portsopen/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. -
A dedicated RTU slave simulator —
diagslave(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:
ModbusCrchelper (~30 lines) + unit test.ModbusRtuOverTcpTransport— can largely reuseModbusTcpTransport'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) —SerialPortopen + the same framing + T3.5 timing.- Config:
Transportdiscriminator + serial fields onModbusDriverOptions, the DTO, and the factory closure (~1 file each). - AdminUI: a serial-parameters panel on the existing
ModbusDriverPage.razor, shown whenTransport != 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 — addJsonStringEnumConvertersoTransport/Parity/StopBitsround-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 containers —
System.IO.Portson 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 theInterFrameDelayMsoverride. 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+ stableby-idpaths, 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 anrtu_over_tcpprofile to.
Sources
- ModbusKit — RTU vs ASCII vs TCP comparison
- ModbusSimulator — RTU vs TCP guide
- Industrial Monitor Direct — Modbus TCP vs RTU-over-TCP protocol differences
- NModbus (C# Modbus, supports serial RTU/ASCII/TCP/UDP)
- NuGet — System.IO.Ports
- Microsoft Q&A — System.IO.Ports platform support
- Mark's Blog — .NET virtual serial ports on macOS
- dotnet/runtime #43719 — macOS SerialPort baud limitation
- Docker forums — expose host serial port to container
- Portainer — mapping host serial/USB devices to containers