# 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` 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`): ```csharp public interface IModbusTransport : IAsyncDisposable { Task ConnectAsync(CancellationToken ct); Task 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? 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)`) — 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 (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:** 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](https://modbuskit.com/en/blog/modbus-rtu-tcp-ascii-comprehensive-comparison), [ModbusSimulator RTU vs TCP](https://modbussimulator.com/blog/modbus-rtu-vs-tcp-comparison-guide), [Industrial Monitor Direct — TCP vs RTU-over-TCP](https://industrialmonitordirect.com/blogs/knowledgebase/modbus-tcp-vs-modbus-rtu-over-tcpip-protocol-differences). --- ## 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 ```json { "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 ```json { "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](https://industrialmonitordirect.com/blogs/knowledgebase/modbus-tcp-vs-modbus-rtu-over-tcpip-protocol-differences)). --- ## 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.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](https://www.nuget.org/packages/system.io.ports/), [MS Q&A](https://learn.microsoft.com/en-us/answers/questions/1444956/is-system-io-ports-currently-only-support-on-windo)) - **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](https://mallibone.com/post/dotnet-on-macos), [dotnet/runtime #43719](https://github.com/dotnet/runtime/issues/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/ttyUSB0` ↔ `ttyUSB1`) 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](https://forums.docker.com/t/how-to-expose-host-serial-port-to-container-correctly/81588), [Portainer device mapping](https://oneuptime.com/blog/post/2026-03-20-map-host-devices-containers-portainer/view)) **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 ` 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 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: - `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 containers** — `System.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 - [ModbusKit — RTU vs ASCII vs TCP comparison](https://modbuskit.com/en/blog/modbus-rtu-tcp-ascii-comprehensive-comparison) - [ModbusSimulator — RTU vs TCP guide](https://modbussimulator.com/blog/modbus-rtu-vs-tcp-comparison-guide) - [Industrial Monitor Direct — Modbus TCP vs RTU-over-TCP protocol differences](https://industrialmonitordirect.com/blogs/knowledgebase/modbus-tcp-vs-modbus-rtu-over-tcpip-protocol-differences) - [NModbus (C# Modbus, supports serial RTU/ASCII/TCP/UDP)](https://github.com/NModbus/NModbus) - [NuGet — System.IO.Ports](https://www.nuget.org/packages/system.io.ports/) - [Microsoft Q&A — System.IO.Ports platform support](https://learn.microsoft.com/en-us/answers/questions/1444956/is-system-io-ports-currently-only-support-on-windo) - [Mark's Blog — .NET virtual serial ports on macOS](https://mallibone.com/post/dotnet-on-macos) - [dotnet/runtime #43719 — macOS SerialPort baud limitation](https://github.com/dotnet/runtime/issues/43719) - [Docker forums — expose host serial port to container](https://forums.docker.com/t/how-to-expose-host-serial-port-to-container-correctly/81588) - [Portainer — mapping host serial/USB devices to containers](https://oneuptime.com/blog/post/2026-03-20-map-host-devices-containers-portainer/view)