docs: driver-expansion program — 8 research reports + 7 design docs, parallel-reviewed
v2-ci / build (push) Successful in 3m14s
v2-ci / unit-tests (push) Failing after 9m13s

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
This commit is contained in:
Joseph Doherty
2026-07-15 16:40:36 -04:00
parent 37cdbef7a5
commit 8fc147d8d4
17 changed files with 6408 additions and 0 deletions
+422
View File
@@ -0,0 +1,422 @@
# 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)