Files
lmxopcua/docs/plans/2026-07-15-modbus-rtu-driver-design.md
T
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

381 lines
21 KiB
Markdown
Raw 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.
# Modbus RTU (serial + RTU-over-TCP) — implementation design
**Status:** Design / build-ready. Not implemented.
**Date:** 2026-07-15
**Scope:** Add Modbus **RTU** transport modes (direct serial + RTU-over-TCP) to the
existing `ModbusDriver`.
**Research input:** [`docs/research/drivers/modbus-rtu.md`](../research/drivers/modbus-rtu.md).
**Related:** [`docs/drivers/Modbus.md`](../drivers/Modbus.md),
[`docs/v2/modbus-addressing.md`](../v2/modbus-addressing.md),
[`docs/plans/2026-07-15-universal-discovery-browser-design.md`](2026-07-15-universal-discovery-browser-design.md).
---
## 1. Motivation + extend-vs-new verdict — **EXTEND**
Add Modbus RTU as **two new `IModbusTransport` implementations behind the existing
transport seam + driver-level config plumbing to select them.** Do **not** create a
sibling driver. The Modbus application protocol above the wire (register model, function
codes FC0106/15/16, exception-PDU convention, data-type codecs, byte order, arrays,
strings, BCD, bit-in-register, read planner + coalescing, auto-prohibit, deadband,
write path, connectivity probe, OPC-UA materialisation, HistoryRead) is **byte-for-byte
identical** across TCP and RTU — only the wire framing and the physical link differ. A
second driver would clone that entire surface for zero benefit.
### The exact seam this plugs into
The driver already splits the *PDU* (function code + data) from the *transport*
(framing + link I/O) behind one interface:
`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); // pdu = [fc, ...data], returns response PDU
}
```
- **`ModbusDriver`** builds every request as a raw PDU `byte[]` and calls
`transport.SendAsync(ResolveUnitId(tag), pdu, ct)` (e.g. `ModbusDriver.cs` lines
~1014/1035/1052/1069/1142/1154). It never touches MBAP, sockets, or CRC.
- **`ModbusTcpTransport`** is the *only* place MBAP framing, the transaction-id counter
(`_nextTx`), and `TcpClient`/`NetworkStream` I/O live — specifically `SendOnceAsync`
(`ModbusTcpTransport.cs` lines 231296) wraps the PDU as
`[TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU`, single-flights via `SemaphoreSlim
_gate`, and owns reconnect/keepalive/idle-disconnect.
- **The transport is injected**: `ModbusDriver`'s ctor takes
`Func<ModbusDriverOptions, IModbusTransport>? transportFactory`
(`ModbusDriver.cs` lines 98114); the default closure builds `ModbusTcpTransport`.
Tests already substitute in-memory fakes through this hook.
So the work is **purely additive**: two new transport classes + a CRC-16 helper +
config/factory selection + one AdminUI panel. **Zero** changes to codecs, planner,
coalescing, health, materialisation, HistoryRead, or the address parser.
### New/changed files at a glance
| File | Change |
|---|---|
| `…/Driver.Modbus/ModbusCrc.cs` | **new** — CRC-16 (poly `0xA001`) helper |
| `…/Driver.Modbus/ModbusRtuFraming.cs` | **new** — shared ADU frame/deframe + FC-aware response sizing |
| `…/Driver.Modbus/ModbusRtuTransport.cs` | **new**`System.IO.Ports.SerialPort` transport |
| `…/Driver.Modbus/ModbusRtuOverTcpTransport.cs` | **new** — RTU framing over a socket |
| `…/Driver.Modbus/ModbusSocketLifecycle.cs` | **new** (refactor) — socket connect/reconnect/keepalive/idle extracted from `ModbusTcpTransport`, shared by TCP + RtuOverTcp |
| `…/Driver.Modbus/ModbusTransportFactory.cs` | **new**`Create(ModbusDriverOptions)` switch on `Transport`; used by the driver default closure **and** the probe |
| `…/Driver.Modbus.Contracts/ModbusDriverOptions.cs` | add `Transport` + serial fields (+ two enums) |
| `…/Driver.Modbus/ModbusDriverFactoryExtensions.cs` | DTO fields + wire the default closure to `ModbusTransportFactory.Create` |
| `…/Driver.Modbus/ModbusDriverProbe.cs` | build the transport via `ModbusTransportFactory` (currently hardcodes `new ModbusTcpTransport`, line 77) |
| `…/Driver.Modbus/ModbusDriver.cs` | `BuildSlaveHostName` endpoint string reflects the transport (COM/gateway vs host:port) |
| `…/AdminUI/…/Drivers/ModbusDriverPage.razor` | serial-parameter panel shown when `Transport != Tcp` |
`System.IO.Ports` (10.0.x for .NET 10) becomes a new PackageReference on
`ZB.MOM.WW.OtOpcUa.Driver.Modbus`.
---
## 2. Transport implementations
Both new transports produce the **RTU ADU**: `[slaveAddress(1)][PDU][CRC-lo][CRC-hi]`
no MBAP header, no transaction id. CRC-16 (poly `0xA001` reflected, appended
**low byte first**) replaces TCP's transport-level integrity. The RTU framing/deframing
logic is identical between the two; factor it into `ModbusRtuFraming` so the serial and
socket transports share it and differ only in the byte-stream they read/write.
### 2a. `ModbusRtuFraming` (shared)
- `byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)``[unit][pdu][crcLo][crcHi]`.
- `int ExpectedResponseLength(byte functionCode)` / a streaming reader that determines
response length **by parsing the function code** (RTU frames are length-less — see
§7). Read `addr(1)` + `fc(1)` first, then:
- **Exception** (`fc & 0x80`): read `excCode(1) + CRC(2)` → 5-byte ADU total; throw
`ModbusException(fc & 0x7F, excCode, …)` after CRC-validating.
- **Read responses** FC01/02/03/04: read `byteCount(1)`, then `byteCount + CRC(2)`.
- **Write echoes** FC05/06/15/16: fixed `addr + qty/value(4) + CRC(2)` → read 6 more.
- After the full ADU is in hand: validate the trailing CRC-16 (mismatch →
`ModbusTransportDesyncException(DesyncReason.…)` so it maps onto the existing
`BadCommunicationError` handling), strip the address byte + CRC, and return the bare
response PDU `[fc, ...data]` — exactly what `SendAsync`'s callers already decode.
### 2b. `ModbusRtuTransport : IModbusTransport` (direct serial)
- Wraps a `System.IO.Ports.SerialPort`. `ConnectAsync` opens the port with the
configured `PortName`/`BaudRate`/`DataBits`/`Parity`/`StopBits`; sets
`ReadTimeout`/`WriteTimeout` from `ModbusDriverOptions.Timeout`.
- **Per-op deadline (R2-01): `SerialPort.ReadTimeout` only governs the synchronous read
API — async reads over `SerialPort.BaseStream` ignore `ReadTimeout`/`WriteTimeout`
entirely** (the exact wall-clock gap class R2-01 found in the S7 driver, where async
socket reads ignored `ReadTimeout` and a frozen peer wedged the poll). Every
transaction MUST therefore run under a linked-CTS
`CancellationTokenSource.CancelAfter(Options.Timeout)` deadline, mirroring
`ModbusTcpTransport.SendOnceAsync` — timeout ⇒ close the port + surface
`ModbusTransportDesyncException(DesyncReason.Timeout)`, distinct from caller
cancellation (no teardown).
- `SendAsync`: single-flight via the same `SemaphoreSlim _gate` pattern (**mandatory** on
RTU — no TxId means at most one transaction on the bus at a time). Enforce ≥3.5-char
inter-frame idle before transmit (computed from baud/word-length, or the
`InterFrameDelayMs` override), write the ADU, then read the response using
`ModbusRtuFraming`'s FC-aware sizing, backstopped by the linked-CTS per-op deadline.
- Failure model is **simpler than TCP** — a COM port doesn't "drop" the way a NAT'd
socket does. On an I/O error or CRC desync, close + reopen the port once and retry
(reuse the single-retry shape from `ModbusTcpTransport.SendAsync`); no keepalive, no
idle-disconnect, no geometric socket backoff.
### 2c. `ModbusRtuOverTcpTransport : IModbusTransport` (RTU tunnelled over a socket)
- **Identical RTU framing** (`ModbusRtuFraming`) but the byte stream rides a
`TcpClient`/`NetworkStream` to a serial→Ethernet gateway instead of a COM port.
- **Reuses the hardened socket lifecycle** — extract the connect (IPv4-preference DNS),
`SO_KEEPALIVE`, idle-disconnect, and reconnect-with-backoff machinery from
`ModbusTcpTransport` into `ModbusSocketLifecycle` and have **both** TCP variants
compose it. The *only* delta from `ModbusTcpTransport` is: CRC framing instead of MBAP,
and no transaction id. This is the lowest-risk of the two RTU transports to build and
test (see §9).
**Modbus ASCII is out of scope** — a third, rarely-used framing (`:`-delimited hex,
LRC instead of CRC). It would be another `IModbusTransport` behind the same seam if ever
needed; not planned.
---
## 3. Cross-platform serial reality
- **`System.IO.Ports.SerialPort` is cross-platform** on .NET 10 (`System.IO.Ports`
10.0.x): Windows + Linux (`/dev/tty*` via termios). **macOS is the weak platform**
(baud quirks, `MacCatalyst` unsupported) — this repo's dev machine is macOS, so
**direct-serial cannot be exercised on the dev Mac**; use fakes / RTU-over-TCP there
and run the real-serial suite on the Linux docker host / CI (§8).
- **Containers add a device-mapping hurdle:** a serial device must be explicitly passed
in (`docker run --device=/dev/ttyUSB0` or a compose `devices:` entry), and USB-serial
adapters re-enumerate (`ttyUSB0``ttyUSB1`) across replug — pin a stable
`/dev/serial/by-id/...` path. Windows-container COM passthrough is unreliable.
**RTU-over-TCP is the recommended primary path.** OtOpcUa deploys as a containerised
Linux server (docker-dev rig; docker host `10.100.0.35`) that is generally **not**
attached to an RS-485 bus. The idiomatic topology is a **serial→Ethernet gateway** (Moxa
NPort, Digi One, Lantronix, USR-TCP232) on the RS-485 multidrop, exposed over TCP. The
server talks **RTU-over-TCP** to it — a plain socket, **zero host-device mapping, no
`System.IO.Ports` dependency at runtime, no udev fragility**, and it reuses the already-
hardened socket lifecycle. Direct serial ships too, for bare-metal Windows/Linux installs
with a local/adapter COM port, but RTU-over-TCP is what most deployments will use.
---
## 4. Config JSON shape
**Per-tag `TagConfig` is unchanged.** `ModbusTagDefinition` / `ModbusTagDto` already carry
everything RTU needs — including the per-tag **`UnitId`** override that makes an RS-485
multi-drop bus "just work" (the read planner already refuses to coalesce across UnitIds;
`ResolveUnitId` + `BuildSlaveHostName` already key per-slave resilience by unit). The
`ModbusTagConfigEditor` in `TagConfigEditorMap` needs **no change**. Additions are
**driver-level only**.
New fields on `ModbusDriverOptions` (and the matching optional fields on
`ModbusDriverConfigDto`):
| Field | Type | Applies to | Notes |
|---|---|---|---|
| `Transport` | `ModbusTransportMode` enum (`Tcp`\|`Rtu`\|`RtuOverTcp`) | all | **Default `Tcp`** (back-compat: existing configs omit it) |
| `SerialPort` | string | Rtu | `"COM3"` / `"/dev/ttyUSB0"` / `by-id` path |
| `BaudRate` | int | Rtu | 9600 / 19200 / 38400 / 115200 |
| `DataBits` | int | Rtu | usually 8 |
| `Parity` | `Parity` enum (`None`\|`Even`\|`Odd`) | Rtu | spec default **Even**; many devices use None |
| `StopBits` | `StopBits` enum (`One`\|`Two`) | Rtu | 1 with parity, 2 without |
| `InterFrameDelayMs` | int? | Rtu | optional override of computed T3.5 for slow/RF links |
| `Host` / `Port` | reused | Tcp, **RtuOverTcp** | the gateway's socket for RtuOverTcp |
`Host`/`Port`/`UnitId`/`TimeoutMs`/`MaxRegistersPerRead`/keepalive/reconnect all stay.
Serial fields are ignored when `Transport=Tcp`; `Host`/`Port` are ignored when
`Transport=Rtu`. Keepalive/idle/reconnect apply to `Tcp` + `RtuOverTcp` only.
> Serialization note: `Parity`/`StopBits` names collide with
> `System.IO.Ports.Parity`/`StopBits` (member names match the table), but **define local
> enums in `Driver.Modbus.Contracts`** and map them to the BCL enums inside
> `ModbusRtuTransport`. Reusing the BCL enums directly would put the `System.IO.Ports`
> package on the Contracts project (and transitively on the AdminUI, which deserializes
> `ModbusDriverOptions`) — contradicting §1's plan to add the dependency to
> `Driver.Modbus` only, and the program-doc rule that Contracts carries no backend
> NuGet dep.
### Example A — direct serial RTU (multi-drop)
```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` is 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). The `Transport` discriminator **must match the gateway's configured mode**:
`"Tcp"` for a gateway doing Modbus/TCP translation, `"RtuOverTcp"` for
transparent/RTU-passthrough. The two wire formats are mutually unparseable.
---
## 5. The `JsonStringEnumConverter` trap — **must handle**
Per the project-wide enum-serialization bug (memory: *Driver enum-serialization bug*),
driver pages serialize enums but factory DTOs are string-typed. The new
`Transport`/`Parity`/`StopBits` enums **must round-trip as strings**, or an AdminUI-
authored RTU config faults the driver at deploy.
Good news — the plumbing is **already correct on both ends** and just needs the new fields:
- `ModbusDriverPage.razor`'s serializer (`_jsonOpts`, line 328334) already has
`Converters = { new JsonStringEnumConverter() }` + camelCase. It serializes a
`ModbusDriverOptions` directly, so a `ModbusTransportMode`/`Parity`/`StopBits` on
`ModbusDriverOptions` emits as `"Rtu"`/`"Even"`/`"One"` automatically.
- `ModbusDriverFactoryExtensions` DTO fields are typed `string?` and parsed via the
existing `ParseEnum<T>` helper (case-insensitive) — mirror how `Family` /
`MelsecSubFamily` are already handled. **Do not** type the DTO fields as the enum.
- `ModbusDriverProbe._opts` (line 1924) already carries `JsonStringEnumConverter`.
Add a driver-page/factory round-trip unit test asserting `"transport":"Rtu"` (string, not
`1`) — the same guard that caught S7/Modbus previously.
---
## 6. Capability mapping — identical to TCP, four deltas
Same register model, function codes, data types, read/write semantics, coalescing,
deadband, `WriteOnChangeOnly`, connectivity probe (FC03@0). **Read + write both fully
supported.** The only deltas are below the seam:
| Delta | TCP (today) | RTU (added) |
|---|---|---|
| Physical link | `TcpClient` | `SerialPort` (Rtu) / `TcpClient` (RtuOverTcp) |
| Framing | 7-byte MBAP + TxId | `[addr][PDU][CRC-16]`, no TxId |
| Integrity | TCP guarantees | app-level CRC-16 (`0xA001`) |
| Unit/slave id | often 1 | **central** — one bus, many drops by unit id (already supported per-tag) |
| Response length | MBAP `Length` field | **no length field** → FC-aware sizing (§7) |
| Timing | none | ≥3.5-char inter-frame silence; single-flight mandatory |
`ResolveHost` / `BuildSlaveHostName` (`ModbusDriver.cs` line 162,
`"{host}:{port}/unit{n}"`) should format the per-slave resilience key from the active
endpoint — `COMx/unit{n}` (Rtu) or `gatewayHost:port/unit{n}` (RtuOverTcp) — so
per-slave breakers stay distinct.
### Browseability — **NO** (reconcile w/ universal browser)
Modbus RTU is **not browsable** — a flat, untyped register space with no discovery
protocol, identical to Modbus TCP. `SupportsOnlineDiscovery=false`; the driver keeps
`RediscoverPolicy = Once` and materialises the authored tag list into a flat folder. No
browser (universal or bespoke). The AdminUI `ModbusAddressPickerBody`/`ModbusAddressBuilder`
is an address *builder* (grammar helper), not a live browser, and stays valid for RTU.
---
## 7. Resilience / timeout
- **Per-op deadline + T3.5 framing.** The linked-CTS `CancelAfter(Options.Timeout)`
per-op deadline (§2b — **not** `SerialPort.ReadTimeout`, which async reads ignore) is
the hard backstop; FC-aware sizing is the primary length signal,
with the inter-frame idle gap as the delimiter fallback. Above 19200 baud the spec
fixes T3.5≈1.75 ms / T1.5≈750 µs rather than scaling further; below it,
T3.5 ≈ 3.5 × (bits-per-char / baud) (e.g. 9600 8-N-1 ≈ 3.6 ms). `InterFrameDelayMs`
overrides for slow/long RS-485 or RF links.
- **The one genuinely new correctness risk: RTU response sizing without a length field.**
Get the FC-aware calculation + exception-PDU short-frame detection right (§2a), or the
read hangs to timeout / mis-frames. Cover exhaustively with fake-stream unit tests
(below). A CRC mismatch or truncated frame maps to
`ModbusTransportDesyncException` → the existing single reconnect-retry + status mapping.
- **RtuOverTcp reuses the socket lifecycle** — keepalive, idle-disconnect, reconnect
backoff, IPv4-preference connect — unchanged from `ModbusTcpTransport` via the extracted
`ModbusSocketLifecycle`. **Direct serial does not** use any of that (a COM port has no
NAT reaping); it uses the simpler close/reopen-once-on-error model.
- **Single-flight is mandatory on RTU** (no TxId to correlate an interleaved response) —
the existing `_gate` semaphore already provides it; keep it in both new transports.
---
## 8. Test fixtures
1. **RTU-over-TCP against pymodbus (highest ROI, no serial hardware).** Add an
`rtu_over_tcp` profile to the existing fixture
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml`
(alongside `standard`/`dl205`/`mitsubishi`/`s7_1500`/`exception_injection`), running
`pymodbus.simulator` in **RTU-framed-TCP** mode. The existing profiles all share host
port `:5020` and are mutually exclusive by design; bind `rtu_over_tcp` to a fresh port
so it can co-run with `standard` (and sidestep the known shared-port stale-container
trap). Add the `project=lmxopcua` label per the program-doc fixture convention (the
checked-in compose services carry no `labels:` entries today). Drive it via
`lmxopcua-fix up modbus rtu_over_tcp` + `lmxopcua-fix sync modbus`. This exercises the
real CRC + FC-aware framing end-to-end with **no serial anything** — the only
genuinely new logic.
2. **Virtual serial pair 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.) Runs on the Linux docker host / CI, **not** the dev Mac.
Defer to an **env-gated live suite** (like the other driver live gates).
3. **`diagslave`** (serial + TCP RTU) for manual/soak against a virtual pair or a real
USB-serial adapter — eventual live gate, not unit CI.
**Unit-level (no PLC):**
- `ModbusCrc` table-driven test against known Modbus CRC vectors.
- `ModbusRtuFraming` / `ModbusRtuTransport` / `ModbusRtuOverTcpTransport` against an
**in-memory duplex stream fake** (fake one level below the existing `IModbusTransport`
fakes — the byte stream) asserting ADU build, CRC append/validate, FC-aware response
parse for each FC group, and exception-PDU short-frame handling.
- Driver-page/factory round-trip test for the `JsonStringEnumConverter` guard (§5).
**Recommended CI shape:** unit (CRC + framing + config round-trip) + the `rtu_over_tcp`
pymodbus profile for integration; real-serial (socat / hardware) env-gated live.
---
## 9. Phasing + effort
**Effort: LOW — likely the lowest-effort item on the driver roadmap.** The seam is
purpose-built; net-new code is small and localised.
- **P0 — shared plumbing:** `ModbusCrc` (~30 lines) + `ModbusRtuFraming` (FC-aware sizing)
+ extract `ModbusSocketLifecycle` from `ModbusTcpTransport` (mechanical refactor;
`ModbusTcpTransport` keeps behaviour) + `ModbusTransportFactory.Create` + config fields
on `ModbusDriverOptions`/DTO + factory closure + probe wiring. Unit tests for CRC +
framing + config round-trip.
- **P1 — `ModbusRtuOverTcpTransport`** (reuses the socket lifecycle; delta = CRC framing +
FC-aware length, no TxId) + `rtu_over_tcp` pymodbus docker profile + AdminUI serial panel
wired for the RtuOverTcp subset. **Ship this first** — no host-device dependency,
testable on the existing pymodbus harness, reuses hardened socket code.
- **P2 — `ModbusRtuTransport`** (direct serial) + `System.IO.Ports` PackageReference +
socat/diagslave env-gated live suite. For bare-metal installs.
**Top risk:** serial framing/timing on Linux/in-containers (best-effort T1.5/T3.5 gating;
device enumeration; the length-less-frame response-sizing correctness). Mitigated by
leading with RTU-over-TCP (P1), exhaustive fake-stream framing tests, and the
`InterFrameDelayMs` override for slow links. Direct-serial (P2) is the residual-risk
piece and is deferred behind the env-gated live suite.