docs(driver-expansion): descope Modbus direct-serial — RTU-over-TCP only
v2-ci / build (push) Successful in 4m10s
v2-ci / unit-tests (push) Failing after 9m7s

Per user decision 2026-07-15: the direct-serial transport (ModbusRtuTransport,
System.IO.Ports, serial config fields, socat live rig) is not being built.
Serial RS-485 buses are reached exclusively via serial->Ethernet gateways
(Moxa NPort etc.) using the new ModbusRtuOverTcpTransport — zero new package
deps, no container device mapping, reuses the hardened socket lifecycle.

ModbusTransportMode shrinks to Tcp|RtuOverTcp (Rtu member reserved). The
direct-serial design is kept in the RTU doc's §2b as a marked record,
including the R2-01 SerialPort-BaseStream-ignores-ReadTimeout trap.
This commit is contained in:
Joseph Doherty
2026-07-15 16:45:40 -04:00
parent 8fc147d8d4
commit fbe393471e
2 changed files with 92 additions and 131 deletions
@@ -145,7 +145,7 @@ means the universal browser is a **separate `IUniversalDriverBrowser`**, not one
| MQTT/Sparkplug | MQTTnet v5 + **hand-rolled Tahu protobuf** | MIT | **Not SparkplugNet** — its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF transitive pinning (Roslyn-split constraint) |
| Omron (CIP) | libplctag.NET | MPL-2.0 | same dep AbCip already ships (`plc=omron-njnx`); FINS hand-rolled |
| SQL poll | Microsoft.Data.SqlClient | — | already in-repo at 6.1.1 — zero new deps for P1 |
| Modbus RTU | extend existing Modbus | — | serial impls behind the **existing** `IModbusTransport` seam; P1 (RTU-over-TCP) zero new deps, P2 direct-serial adds `System.IO.Ports` |
| Modbus RTU | extend existing Modbus | — | **RTU-over-TCP only** behind the **existing** `IModbusTransport` seam; zero new deps. Direct serial (`System.IO.Ports`) **descoped** (user, 2026-07-15) — serial buses are reached via serial→Ethernet gateways |
| ~~MELSEC~~ | ~~hand-rolled 3E~~ | — | deferred; HslCommunication is a commercial-license blocker |
**Avoid HslCommunication** (commercial license; the "MIT" claim is a stale abandoned fork) for both
@@ -189,7 +189,8 @@ MELSEC is out of the sequence (deferred).
- Fixtures live under `tests/.../Docker/`, deployed to the shared host `10.100.0.35` via
`lmxopcua-fix sync` (which owns the `project=lmxopcua` labelling host-side). Per-driver sims: `mtconnect/cppagent`, Mosquitto/EMQX +
Sparkplug simulator, `bacnet-stack`/ela-compil `BasicServer`, central SQL Server
(`10.100.0.35,14330`) + seeded table, `rtu_over_tcp` pymodbus profile + socat serial pair.
(`10.100.0.35,14330`) + seeded table, `rtu_over_tcp` pymodbus profile (no serial rig —
direct-serial descoped).
- **Env-gated live suites** for what a sim can't cover: BACnet Who-Is/BBMD UDP broadcast, Omron CIP
wire (live hardware), MQTT broker soak.
- Per driver: unit (framer/parser/type-map with golden vectors + fakes) → integration (docker
@@ -198,6 +199,9 @@ MELSEC is out of the sequence (deferred).
## 8. Deferred / out of scope
- **MELSEC SLMP** — deferred (§1). Research complete (`melsec-slmp.md`); revisit on demand.
- **Modbus direct-serial transport** (`ModbusRtuTransport` / `System.IO.Ports`) — descoped
(user, 2026-07-15); design record kept in the RTU doc §2b. RTU-over-TCP via a
serial→Ethernet gateway is the only shipped RTU mode.
- **Write-back** for MTConnect (Interfaces), MQTT (NCMD/DCMD), BACnet (WriteProperty priority
array), SQL (parameterized UPSERT) — all documented as later phases in the respective designs.
- **Bespoke lazy browsers** for large BACnet sites and large ControlLogix/TwinCAT symbol sets —
+86 -129
View File
@@ -1,9 +1,13 @@
# Modbus RTU (serial + RTU-over-TCP) — implementation design
# Modbus RTU (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`.
**Scope:** Add the Modbus **RTU-over-TCP** transport mode to the existing `ModbusDriver`.
**Descoped (user, 2026-07-15):** the direct-serial transport (`System.IO.Ports` /
`ModbusRtuTransport`) is **not being built**. RTU buses are reached exclusively via a
serial→Ethernet gateway (§3 explains why that's the right topology for this containerised
server anyway). §2b and the serial-specific config/fixture material are retained below,
clearly marked, as a design record should bare-metal serial ever be needed.
**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),
@@ -13,8 +17,8 @@ existing `ModbusDriver`.
## 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
Add Modbus RTU as **a new `IModbusTransport` implementation behind the existing
transport seam + driver-level config plumbing to select it.** 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,
@@ -50,8 +54,8 @@ public interface IModbusTransport : IAsyncDisposable
(`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,
So the work is **purely additive**: one new transport class + a CRC-16 helper +
config/factory selection + one AdminUI field. **Zero** changes to codecs, planner,
coalescing, health, materialisation, HistoryRead, or the address parser.
### New/changed files at a glance
@@ -60,28 +64,28 @@ coalescing, health, materialisation, HistoryRead, or the address parser.
|---|---|
| `…/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/ModbusRtuTransport.cs`~~ | ~~`System.IO.Ports.SerialPort` transport~~**descoped** (§2b kept as design record) |
| `…/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.Contracts/ModbusDriverOptions.cs` | add the `Transport` discriminator enum |
| `…/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` |
| `…/AdminUI/…/Drivers/ModbusDriverPage.razor` | `Transport` selector (Tcp / RtuOverTcp) |
`System.IO.Ports` (10.0.x for .NET 10) becomes a new PackageReference on
`ZB.MOM.WW.OtOpcUa.Driver.Modbus`.
**No new package dependency**`System.IO.Ports` was only needed by the descoped
direct-serial transport.
---
## 2. Transport implementations
Both new transports produce the **RTU ADU**: `[slaveAddress(1)][PDU][CRC-lo][CRC-hi]`
The transport produces 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.
lives in its own `ModbusRtuFraming` class (framing is byte-stream-agnostic, so a future
direct-serial transport would share it unchanged).
### 2a. `ModbusRtuFraming` (shared)
@@ -98,7 +102,11 @@ socket transports share it and differ only in the byte-stream they read/write.
`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)
### 2b. ~~`ModbusRtuTransport : IModbusTransport` (direct serial)~~ — **DESCOPED** (design record only)
> Not being built (user, 2026-07-15). Kept verbatim below so a future bare-metal-serial
> need doesn't re-derive it — the R2-01 `BaseStream`-ignores-`ReadTimeout` finding in
> particular.
- Wraps a `System.IO.Ports.SerialPort`. `ConnectAsync` opens the port with the
configured `PortName`/`BaudRate`/`DataBits`/`Parity`/`StopBits`; sets
@@ -130,8 +138,7 @@ socket transports share it and differ only in the byte-stream they read/write.
`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).
and no transaction id. Low-risk 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
@@ -139,26 +146,20 @@ needed; not planned.
---
## 3. Cross-platform serial reality
## 3. Why RTU-over-TCP only (the serial reality that drove the descope)
- **`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.
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, no udev
fragility**, and it reuses the already-hardened socket lifecycle.
**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.
Direct serial was descoped because every one of its costs lands on the deployment side:
`SerialPort` is macOS-weak (this repo's dev machine — untestable locally), containers
need explicit `--device=` mapping, USB-serial adapters re-enumerate across replug
(needing `/dev/serial/by-id/...` pinning), and Windows-container COM passthrough is
unreliable. A gateway sidesteps all of it for the price of commodity hardware.
---
@@ -171,58 +172,23 @@ multi-drop bus "just work" (the read planner already refuses to coalesce across
`ModbusTagConfigEditor` in `TagConfigEditorMap` needs **no change**. Additions are
**driver-level only**.
New fields on `ModbusDriverOptions` (and the matching optional fields on
One new field on `ModbusDriverOptions` (and a matching optional field 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 |
| `Transport` | `ModbusTransportMode` enum (`Tcp`\|`RtuOverTcp`) | all | **Default `Tcp`** (back-compat: existing configs omit it). An `Rtu` member is reserved for a future direct-serial leg — do not number-squat it |
| `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.
`Host`/`Port`/`UnitId`/`TimeoutMs`/`MaxRegistersPerRead`/keepalive/reconnect all stay and
apply identically to both modes. The descoped direct-serial leg would have added
`SerialPort`/`BaudRate`/`DataBits`/`Parity`/`StopBits`/`InterFrameDelayMs` (with local
enums in Contracts, NOT the `System.IO.Ports` BCL enums, to keep Contracts
backend-dep-free) — none of that ships now. Per-tag multi-drop still works today via the
existing per-tag `unitId` override (the RS-485 drops sit behind the gateway; the planner
already refuses to coalesce across UnitIds).
> 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
### Example — RTU-over-TCP to a serial→Ethernet gateway
```json
{
@@ -248,23 +214,23 @@ 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.
driver pages serialize enums but factory DTOs are string-typed. The new `Transport` enum
**must round-trip as a string**, 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:
Good news — the plumbing is **already correct on both ends** and just needs the new field:
- `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.
`ModbusDriverOptions` directly, so a `ModbusTransportMode` on `ModbusDriverOptions`
emits as `"RtuOverTcp"` 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.
`MelsecSubFamily` are already handled. **Do not** type the DTO field 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.
Add a driver-page/factory round-trip unit test asserting `"transport":"RtuOverTcp"`
(string, not a number) — the same guard that caught S7/Modbus previously.
---
@@ -274,19 +240,18 @@ Same register model, function codes, data types, read/write semantics, coalescin
deadband, `WriteOnChangeOnly`, connectivity probe (FC03@0). **Read + write both fully
supported.** The only deltas are below the seam:
| Delta | TCP (today) | RTU (added) |
| Delta | TCP (today) | RtuOverTcp (added) |
|---|---|---|
| Physical link | `TcpClient` | `SerialPort` (Rtu) / `TcpClient` (RtuOverTcp) |
| Physical link | `TcpClient` | `TcpClient` (to the serial→Ethernet gateway) |
| 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 |
| Timing | none | single-flight mandatory (no TxId); bus-side T3.5 idle gating is the gateway's job |
`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.
`"{host}:{port}/unit{n}"`) already produces the right per-slave resilience key for
RtuOverTcp (`gatewayHost:port/unit{n}`) — no change needed.
### Browseability — **NO** (reconcile w/ universal browser)
@@ -300,13 +265,11 @@ is an address *builder* (grammar helper), not a live browser, and stays valid fo
## 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.
- **Per-op deadline.** The linked-CTS `CancelAfter(Options.Timeout)` per-op deadline
(exactly as `ModbusTcpTransport.SendOnceAsync` does today) is the hard backstop; a
frozen gateway must never wedge a poll (R2-01). FC-aware sizing is the length signal
there is no MBAP length field to trust. Bus-side T1.5/T3.5 inter-frame timing is the
serial→Ethernet gateway's responsibility, not this transport's.
- **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
@@ -314,8 +277,7 @@ is an address *builder* (grammar helper), not a live browser, and stays valid fo
`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.
`ModbusSocketLifecycle`.
- **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.
@@ -335,24 +297,20 @@ is an address *builder* (grammar helper), not a live browser, and stays valid fo
`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.
2. **`diagslave`** (TCP RTU mode) for manual/soak — optional, not unit CI.
3. ~~Virtual serial pair (socat pty ↔ pymodbus RTU serial slave; com0com on Windows)~~
**descoped with the direct-serial transport**; would return with it.
**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.
- `ModbusRtuFraming` / `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.
pymodbus profile for integration. No live serial gate — nothing serial ships.
---
@@ -363,18 +321,17 @@ 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 +
`ModbusTcpTransport` keeps behaviour) + `ModbusTransportFactory.Create` + the `Transport`
field 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.
FC-aware length, no TxId) + `rtu_over_tcp` pymodbus docker profile + the AdminUI
`Transport` selector. No host-device dependency, testable on the existing pymodbus
harness, reuses hardened socket code.
- ~~**P2 — `ModbusRtuTransport`** (direct serial)~~ — **DESCOPED** (user, 2026-07-15).
§2b holds the design record if it's ever revived.
**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.
**Top risk:** the length-less-frame response-sizing correctness (FC-aware sizing +
exception-PDU short frames). Mitigated by exhaustive fake-stream framing tests and the
pymodbus RTU-over-TCP integration profile. All the serial-specific risks (T1.5/T3.5
gating, device enumeration, container device mapping) left with the descope.