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

423 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`):
```csharp
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](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
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](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 <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 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)