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

Open
dohertj2 wants to merge 15 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
Some checks are pending
v2-ci / build (pull_request) Successful in 3m45s
v2-ci / unit-tests (pull_request) Failing after 13m54s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/modbus-rtu-driver:feat/modbus-rtu-driver
git checkout feat/modbus-rtu-driver
Sign in to join this conversation.