8fc147d8d4
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
423 lines
24 KiB
Markdown
423 lines
24 KiB
Markdown
# 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
|
||
(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 <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)
|