feat(modbus-rtu): Modbus RTU-over-TCP transport (Wave 1) #495

Closed
dohertj2 wants to merge 0 commits from feat/modbus-rtu-driver into master
Owner

Summary

Adds a Modbus RTU-over-TCP transport (RTU CRC-16 framing tunnelled to a serial→Ethernet gateway) to the existing ModbusDriver, selected by a new Transport config field. Purely additive behind the existing IModbusTransport seam — the application protocol (function codes, codecs, planner, coalescing, write path, probe, materialisation, HistoryRead) is byte-for-byte identical across TCP and RTU; only the wire framing differs ([addr][PDU][CRC-16], no MBAP, no TxId). Zero changes to codecs/planner/health.

Wave 1 of the driver-expansion program. Plan: docs/plans/2026-07-24-modbus-rtu-driver.md (11 tasks).

What changed

  • ModbusTransportMode enum (Tcp | RtuOverTcp); ModbusCrc (CRC-16/MODBUS, golden vectors); ModbusRtuFraming (ADU build + FC-aware length-less deframe + response-unit validation + partial-read/truncation handling).
  • ModbusSocketLifecycle extracted from ModbusTcpTransport (behaviour-preserving) and composed by both transports; new ModbusRtuOverTcpTransport (single-flight gate — no TxId, per-op CancelAfter deadline, timeout-vs-caller-cancel distinction, ForTest stream-injection seam).
  • ModbusTransportFactory switch (throws on unknown mode); Transport bound on options/DTO/factory with the string-enum guard; driver default closure + Test-Connect probe routed through the factory.
  • AdminUI Transport selector on ModbusDriverForm (Protocol panel).
  • rtu_over_tcp pymodbus docker profile (framer=rtu, host :5021, co-runs with standard) + fixture + read/write integration tests. Confirmed on the wire that pymodbus 3.13 honours framer=rtu on a TCP server — no stdlib fallback needed.

Descoped (by design)

Direct-serial transport (System.IO.Ports) and Modbus ASCII — RTU buses are reached exclusively via a serial→Ethernet gateway. No per-tag config changes (the existing per-tag UnitId already makes a multi-drop bus work).

Test Plan

  • Modbus unit suite 344/344 green; full solution build 0 errors.
  • RTU integration tests pass live against a real framer=rtu pymodbus fixture (10.100.0.35:5021): read HR5→5, write+readback HR200←4242.
  • Per-task spec+code review chain + a final whole-branch review — all approved-to-merge.
  • Live /run gate on docker-dev (rig rebuilt from this branch): AdminUI Transport selector renders + persists RtuOverTcp; device Test Connect → OK · 23 ms (probe spoke FC03 over RTU framing); deployment 06ec1632 Sealed (6/6 nodes); Client.CLI read ns=2;s=rtu-gate/Device1/HR5 → 5/Good, write HR200=4242 → readback 4242/Good.

Follow-ups (non-blocking, tracked)

  • Add CrcMismatch / UnitMismatch members to DesyncReason (currently all RTU desync modes report TruncatedFrame; no downstream .Reason consumer today).

https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW

## Summary Adds a **Modbus RTU-over-TCP** transport (RTU CRC-16 framing tunnelled to a serial→Ethernet gateway) to the existing `ModbusDriver`, selected by a new `Transport` config field. Purely additive behind the existing `IModbusTransport` seam — the application protocol (function codes, codecs, planner, coalescing, write path, probe, materialisation, HistoryRead) is byte-for-byte identical across TCP and RTU; only the wire framing differs (`[addr][PDU][CRC-16]`, no MBAP, no TxId). **Zero** changes to codecs/planner/health. Wave 1 of the driver-expansion program. Plan: `docs/plans/2026-07-24-modbus-rtu-driver.md` (11 tasks). ### What changed - `ModbusTransportMode` enum (`Tcp` | `RtuOverTcp`); `ModbusCrc` (CRC-16/MODBUS, golden vectors); `ModbusRtuFraming` (ADU build + FC-aware length-less deframe + response-unit validation + partial-read/truncation handling). - `ModbusSocketLifecycle` extracted from `ModbusTcpTransport` (behaviour-preserving) and composed by both transports; new `ModbusRtuOverTcpTransport` (single-flight gate — no TxId, per-op `CancelAfter` deadline, timeout-vs-caller-cancel distinction, `ForTest` stream-injection seam). - `ModbusTransportFactory` switch (throws on unknown mode); `Transport` bound on options/DTO/factory with the string-enum guard; driver default closure + Test-Connect probe routed through the factory. - AdminUI `Transport` selector on `ModbusDriverForm` (Protocol panel). - `rtu_over_tcp` pymodbus docker profile (`framer=rtu`, host `:5021`, co-runs with `standard`) + fixture + read/write integration tests. **Confirmed on the wire that pymodbus 3.13 honours `framer=rtu` on a TCP server** — no stdlib fallback needed. ### Descoped (by design) Direct-serial transport (`System.IO.Ports`) and Modbus ASCII — RTU buses are reached exclusively via a serial→Ethernet gateway. No per-tag config changes (the existing per-tag `UnitId` already makes a multi-drop bus work). ## Test Plan - [x] Modbus unit suite **344/344** green; full solution build **0 errors**. - [x] RTU integration tests pass **live** against a real `framer=rtu` pymodbus fixture (`10.100.0.35:5021`): read HR5→5, write+readback HR200←4242. - [x] Per-task spec+code review chain + a final whole-branch review — all approved-to-merge. - [x] **Live `/run` gate on docker-dev (rig rebuilt from this branch):** AdminUI Transport selector renders + persists `RtuOverTcp`; device **Test Connect → OK · 23 ms** (probe spoke FC03 over RTU framing); deployment `06ec1632` **Sealed** (6/6 nodes); Client.CLI **read** `ns=2;s=rtu-gate/Device1/HR5` → 5/Good, **write** HR200=4242 → readback 4242/Good. ## Follow-ups (non-blocking, tracked) - Add `CrcMismatch` / `UnitMismatch` members to `DesyncReason` (currently all RTU desync modes report `TruncatedFrame`; no downstream `.Reason` consumer today). https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
dohertj2 added 15 commits 2026-07-24 15:52:27 -04:00
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
docs(modbus-rtu): record Wave-1 RTU-over-TCP live /run gate result (PASSED)
v2-ci / build (pull_request) Successful in 3m45s
v2-ci / unit-tests (pull_request) Failing after 13m54s
e27eb77187
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
dohertj2 added 1 commit 2026-07-25 15:13:53 -04:00
refactor(modbus-rtu): distinct CrcMismatch/UnitMismatch desync reasons
v2-ci / build (pull_request) Successful in 4m1s
v2-ci / unit-tests (pull_request) Failing after 13m33s
ee12568cab
The RTU framer overloaded DesyncReason.TruncatedFrame for three distinct
failures — a genuine short read, a CRC-16 validation failure, and a
CRC-valid reply from the wrong RS-485 slave. Split the latter two into
dedicated CrcMismatch / UnitMismatch enum members so a future .Reason
consumer (metrics, operator diagnostics) can tell a wiring/EMI CRC fault
apart from a mis-addressed multi-drop reply. Behaviour is unchanged — all
three still throw ModbusTransportDesyncException and tear the socket down.

Framing tests now assert the specific Reason on each path.

Follow-up #12 from the Modbus RTU-over-TCP plan.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Author
Owner

Merged to master in 0f38f486. No code changes were needed — the branch was complete and live-gated; it had simply never been merged, sitting 15 commits ahead and 50 behind master.

Because that gap is large enough that the branch's own gate no longer proves anything about master, I re-validated at merge time rather than trusting it:

  • No overlapping files between the two sides since the fork point, so the merge is textually clean.
  • Solution builds 0 errors.
  • Modbus 344/344 (matching the figure in this issue), Modbus.Addressing 161/161, AdminUI 740/740, Runtime 507/507, Configuration 131/131.
  • Confirmed the Transport selector is still reachable through master's current authoring path (DriverConfigModal -> ModbusDriverForm) — that is the check that would have caught a "registered but unauthorable" regression, the failure mode the Sql driver hit, where a driver is wired end-to-end but no AdminUI path can author it. It holds, so the branch's live /run gate result still applies.
Merged to master in `0f38f486`. No code changes were needed — the branch was complete and live-gated; it had simply never been merged, sitting 15 commits ahead and **50 behind** master. Because that gap is large enough that the branch's own gate no longer proves anything about master, I re-validated at merge time rather than trusting it: - No overlapping files between the two sides since the fork point, so the merge is textually clean. - Solution builds **0 errors**. - **Modbus 344/344** (matching the figure in this issue), Modbus.Addressing 161/161, AdminUI 740/740, Runtime 507/507, Configuration 131/131. - Confirmed the `Transport` selector is still reachable through master's **current** authoring path (`DriverConfigModal` -> `ModbusDriverForm`) — that is the check that would have caught a "registered but unauthorable" regression, the failure mode the Sql driver hit, where a driver is wired end-to-end but no AdminUI path can author it. It holds, so the branch's live `/run` gate result still applies.
dohertj2 closed this pull request 2026-07-26 11:08:35 -04:00
Some checks are pending
v2-ci / build (pull_request) Successful in 4m1s
v2-ci / unit-tests (pull_request) Failing after 13m33s

Pull request closed

Sign in to join this conversation.