From fbe393471ef3ba7cc85e49f5fa4c55f2d5e5949e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:45:40 -0400 Subject: [PATCH] =?UTF-8?q?docs(driver-expansion):=20descope=20Modbus=20di?= =?UTF-8?q?rect-serial=20=E2=80=94=20RTU-over-TCP=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...6-07-15-driver-expansion-program-design.md | 8 +- .../2026-07-15-modbus-rtu-driver-design.md | 215 +++++++----------- 2 files changed, 92 insertions(+), 131 deletions(-) diff --git a/docs/plans/2026-07-15-driver-expansion-program-design.md b/docs/plans/2026-07-15-driver-expansion-program-design.md index a55f892d..59704bb3 100644 --- a/docs/plans/2026-07-15-driver-expansion-program-design.md +++ b/docs/plans/2026-07-15-driver-expansion-program-design.md @@ -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 — diff --git a/docs/plans/2026-07-15-modbus-rtu-driver-design.md b/docs/plans/2026-07-15-modbus-rtu-driver-design.md index cc0d3bd4..a2ac4131 100644 --- a/docs/plans/2026-07-15-modbus-rtu-driver-design.md +++ b/docs/plans/2026-07-15-modbus-rtu-driver-design.md @@ -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 FC01–06/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 98–114); 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 328–334) 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` 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 19–24) 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.