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
381 lines
21 KiB
Markdown
381 lines
21 KiB
Markdown
# 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 FC01–06/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 231–296) 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 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,
|
||
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 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.
|
||
- `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 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.
|
||
|
||
---
|
||
|
||
## 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.
|