Compare commits

...

21 Commits

Author SHA1 Message Date
Joseph Doherty ee12568cab 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
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
2026-07-25 15:13:48 -04:00
Joseph Doherty e27eb77187 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
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:50:10 -04:00
Joseph Doherty fcc7ed698e harden(modbus-rtu): factory throws on unknown transport + document RTU FC-shape assumption
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:33:49 -04:00
Joseph Doherty 4a8c9badce docs(modbus-rtu): note the co-running :5021 rtu_over_tcp fixture in Docker README + Dockerfile
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:36:00 -04:00
Joseph Doherty 650e27075f test(modbus-rtu): add rtu_over_tcp pymodbus fixture + RTU-over-TCP integration tests
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:29:10 -04:00
Joseph Doherty 132e7b63f6 feat(modbus-rtu): add Transport selector to the AdminUI Modbus driver form
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:19:15 -04:00
Joseph Doherty 13bd9820b0 feat(modbus-rtu): route driver + probe transports through ModbusTransportFactory
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:12:12 -04:00
Joseph Doherty 1d90bf3611 feat(modbus-rtu): add ModbusTransportFactory switch on Transport mode
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:08:10 -04:00
Joseph Doherty 20be5416b9 feat(modbus-rtu): bind Transport mode on options/DTO/factory (string-enum guard)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:04:34 -04:00
Joseph Doherty 47e9cd56ef feat(modbus-rtu): add ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:56:19 -04:00
Joseph Doherty 2f38b5b285 refactor(modbus): extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:47:18 -04:00
Joseph Doherty 20100c36ad feat(modbus-rtu): validate response unit + cover partial-read/truncation in ModbusRtuFraming
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:42:25 -04:00
Joseph Doherty eed6617784 feat(modbus-rtu): add ModbusRtuFraming (ADU build + FC-aware length-less deframe)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:33:59 -04:00
Joseph Doherty 8d0f60ec51 feat(modbus-rtu): add ModbusCrc CRC-16/MODBUS helper + golden vectors
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:30:05 -04:00
Joseph Doherty e787aa572b feat(modbus-rtu): add ModbusTransportMode enum (Tcp | RtuOverTcp)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:28:30 -04:00
Joseph Doherty 963eec1b32 chore: gitignore .worktrees/ for subagent-driven-development
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:26:01 -04:00
Joseph Doherty 7a9caf141c docs(drivers): add worktree + subagent execution commands to the tracking doc
v2-ci / build (push) Successful in 3m46s
v2-ci / unit-tests (push) Failing after 14m11s
Per-plan copy-paste command (subagent-driven-development in a git worktree),
plus the slash-command and executing-plans/resume alternatives, and a note that
the four independent plans can run in concurrent worktrees.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:24:36 -04:00
Joseph Doherty 88adec047b refactor(health): adopt the shared active-node check (Health 0.3.0)
ClusterPrimaryHealthCheck was written three days' worth of debugging ago because
the shared ActiveNodeHealthCheck selected by RoleLeader and reported Healthy for
any node lacking the role. Health 0.3.0 fixes the shared check — oldest Up
member, role-preference scoping, Unhealthy when the node owns no active work —
so the private copy is now duplication rather than a workaround, and it is
deleted.

RolePreference [admin, driver] reproduces exactly what it did: a fused central
node answers for admin (where the singletons and the AdminUI are pinned), a
driver-only site node answers for driver.

SelectOldestUpMemberOfRole now delegates to the shared ClusterActiveNode instead
of re-implementing the age ordering. This is the point of the change rather than
a tidy-up: the redundancy snapshot drives the OPC UA ServiceLevel 250/240 split
while the health tier drives Traefik's admin routing, so two copies of "oldest Up
member of a role" would be two chances to advertise one node as authoritative
while gating the data plane on another. ControlPlane takes a ZB.MOM.WW.Health.Akka
reference for it; the layering trade-off is recorded at the PackageReference.

Behaviour note: a node carrying neither admin nor driver now answers 503 on the
active tier instead of 200. That case is reachable — RoleParser admits dev-only
and cluster-role-only nodes — and 503 is correct, since such a node owns no
active work and must not be in an active-tier pool. No docker-dev node is
affected: every rig node is admin+driver or driver-only.

Tests replaced in kind. The rule itself is now pinned in the library against a
real two-node cluster; what stays OtOpcUa's own decision is the wiring, so
ActiveTierRegistrationTests pins which check is on the active tag, that it is
registered on driver-only nodes too, and that it is not on the ready tier.

Verified: Host.Tests 25/25, ControlPlane redundancy 15/15.
2026-07-24 13:21:11 -04:00
Joseph Doherty 76e55fc112 docs(drivers): Wave 0-2 tracking doc + Wave 1/2 implementation plans
Add a living progress tracker (docs/plans/2026-07-24-driver-expansion-tracking.md)
for the driver-expansion program's Waves 0-2, linked from the program design doc's
document map. One row per deliverable pointing at design + implementation plan + status.

Add executable implementation plans (plan .md + co-located .tasks.json) for the four
Wave 1/2 drivers, each authored by writing-plans methodology off its design doc and
mirroring the relevant existing driver:

- Modbus RTU        11 tasks  (extends Driver.Modbus; RTU-over-TCP, direct-serial descoped)
- SQL poll          22 tasks  (ISqlDialect seam, SQL Server only in v1; SQLite + central-SQL fixtures)
- MTConnect Agent   23 tasks  (browse free via Wave-0 seam; TrakHound-vs-hand-rolled = Task 0)
- MQTT/Sparkplug B  27 tasks  (P1 plain MQTT 0-14 then P2 Sparkplug 15-26; MQTTnet-5 pinning spike = Task 0)

Wave 0 (universal browser) remains merged (056887d6) with its live gate open (#468).
All four new plans are design-only -> plan-ready; nothing built yet.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:17:44 -04:00
Joseph Doherty 4550486144 fix(health): active tier reports the cluster Primary, not the admin role leader
Closes the remaining half of #494, and corrects the half fixed in 8dd9da7d.

The shared ActiveNodeHealthCheck(role: "admin") answered the wrong question twice
over. It returns Healthy for any node LACKING the role, so all four driver-only
site nodes called themselves active and no consumer could find the Primary of a
site Cluster. And it selects by RoleLeader - the lowest-ADDRESSED member - which
is not where Akka places singletons.

Replaced with ClusterPrimaryHealthCheck ("cluster-primary"). One rule: this node
is active iff it is the OLDEST Up member carrying its own active role, where the
active role is admin when the node has it and driver otherwise. That serves both
consumers correctly - a fused admin node answers for admin, so Traefik pins the
AdminUI to the node hosting the singletons; a driver-only site node answers for
driver, which is exactly SelectDriverPrimary, the same election behind
IsDriverPrimary and the OPC UA ServiceLevel 250/240 split. Per-mesh scoping is
free after Phase 6: ClusterState.Members already contains only this node's own
Cluster.

SelectDriverPrimary is generalised to SelectOldestUpMemberOfRole so the
age-ordering rule has one implementation. Two copies of "oldest Up member of a
role" would be two chances to silently disagree about who is in charge, and the
tier and the redundancy snapshot must never disagree.

THE ROLE-LEADER HALF MATTERED IN PRACTICE, not just in theory. On the rebuilt rig
the two orderings diverge right now:

  akka leader (lowest address) = central-1
  oldest admin member          = central-2   <- hosts the singletons

8dd9da7d made the tier a real 503 but still selected by RoleLeader, so it would
have pinned Traefik to central-1 - the node NOT hosting the work. With this change
Traefik correctly routes to central-2.

Live-verified, exactly one 200 per mesh:

  MAIN     central-2 200   central-1 503     traefik: central-2 UP, central-1 DOWN
  SITE-A   site-a-1  200   site-a-2  503
  SITE-B   site-b-1  200   site-b-2  503

Tests: role-selection matrix and the startup-safe Degraded path in Host.Tests
(26 pass); parity between the tier's selector and the redundancy snapshot's, plus
role scoping, added to RedundancyPrimaryElectionTests, which forms a real two-node
cluster deliberately built so the oldest member is not the lowest-addressed one
(5 pass).
2026-07-24 12:43:27 -04:00
Joseph Doherty 8dd9da7d4d fix(health): bump to ZB.MOM.WW.Health 0.2.1 — Traefik leader-pinning now works
0.2.1 makes the shared ActiveNodeHealthCheck return Unhealthy (503), not
Degraded (200), for a node that carries the role but is not the role leader.
That is what the shared health spec always specified, what docs/ServiceHosting.md,
docs/v2/Architecture-v2.md and the 2026-05-26 alignment design all describe
("200 only on the Admin-role leader; 503 elsewhere"), and what
docker-dev/traefik-dynamic.yml + scripts/install/traefik-dynamic.yml assume when
they use /health/active as the load-balancer probe.

The implementation returned Degraded, which MapZbHealth maps to 200, so BOTH
central nodes always passed the probe and Traefik load-balanced the admin UI
across the pair instead of pinning it to the leader. The docs were right; the
code was wrong. Closes the Traefik half of #494.

No OtOpcUa code change — package pin only.

Live-verified on docker-dev, with the MAIN pair genuinely formed (memberCount 2,
both members naming one leader):

  central-1 (leader)  /health/active -> 200   traefik UP
  central-2 (standby) /health/active -> 503   traefik DOWN

Failover drill (README step 2), which had never actually exercised anything:
stopping central-1 flipped Traefik to central-2 within 20 s and
http://localhost:9200/ answered 200 continuously across the swap — no outage.
Restarting central-1 reclaimed the role-leader and Traefik flipped back.

README corrected on two counts: the drill now states that exactly ONE backend is
UP by design, and step 3 no longer claims central-2 keeps leadership — RoleLeader
is the lowest-address member, so central-1 deterministically reclaims it. Added
the cold-boot caveat that starting both nodes at once can leave each self-forming
a 1-member cluster, in which case both are their own leader and both answer 200.

STILL OPEN in #494: driver-only nodes. The check is scoped to the admin role and
returns Healthy for any node that lacks it, so all four site nodes answer 200 and
the real per-Cluster redundancy Primary (oldest Up driver member, IsDriverPrimary)
is still not what this tier reports.
2026-07-24 11:30:50 -04:00
47 changed files with 5144 additions and 205 deletions
+3
View File
@@ -58,3 +58,6 @@ lib/
# Session working notes — never stage (pending.md's own hard rule; R2-12/U-4)
/pending.md
/current.md
# Subagent-driven-development git worktrees
.worktrees/
+3 -3
View File
@@ -140,7 +140,7 @@
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
<PackageVersion Include="xunit.v3" Version="1.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.2.0" />
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.3.0" />
<!--
LocalDb: embedded SQLite node-local cache + optional 2-node gRPC replication. The core
package floors the native SQLitePCLRaw.lib.e_sqlite3 at 2.1.12, so consumers inherit the
@@ -148,8 +148,8 @@
-->
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.3" />
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.3" />
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.2.0" />
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.2.0" />
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.1" />
+5 -3
View File
@@ -100,9 +100,11 @@ The `-v` drops the SQL volume; remove it to keep ConfigDb state across restarts.
## Failover smoke
1. Watch the Traefik dashboard at `http://localhost:8089`. Both `central-1` and `central-2` should be listed as healthy in the `otopcua-admin` service.
2. `docker compose -f docker-dev/docker-compose.yml stop central-1``central-2` should pick up the admin role-leader within ~15 s (Akka split-brain stable-after). Traefik will route traffic to `central-2` once its `/health/active` returns 200.
3. `docker compose -f docker-dev/docker-compose.yml start central-1``central-1` rejoins as a follower; `central-2` keeps the leader role until something disturbs it.
1. Watch the Traefik dashboard at `http://localhost:8089`. Exactly ONE of `central-1` / `central-2` is UP in the `otopcua-admin` service — the admin role-leader. The standby is DOWN by design: `/health/active` answers 503 on it, which is how Traefik pins browser traffic to the leader. (Before `ZB.MOM.WW.Health` 0.2.1 the standby answered 200, so both showed UP and this pinning silently never worked — see `lmxopcua#494`.)
2. `docker compose -f docker-dev/docker-compose.yml stop central-1``central-2` picks up the admin role-leader within ~15 s (Akka split-brain stable-after) and Traefik flips it to UP. Verified 2026-07-24: the swap completed inside 20 s with **no gap**`http://localhost:9200/` answered 200 continuously across the failover.
3. `docker compose -f docker-dev/docker-compose.yml start central-1``central-1` rejoins and **reclaims** the admin role-leader, and Traefik flips back. It does not stay with `central-2`: Akka's `RoleLeader` is the lowest-address member, so the outcome is deterministic rather than sticky.
> **Cold-boot caveat.** Starting both central nodes simultaneously from stopped can leave each self-forming its own 1-member cluster (self-first seed lists), in which case BOTH are their own role-leader and both answer `/health/active` 200. Check `entries["akka"].data.memberCount` on `/health/ready` — it should read 2. Restarting either node makes it join the survivor.
## Resource limits & dev logging
@@ -26,7 +26,9 @@ OpcUaClient, Historian.Gateway (`src/Drivers/`).
## 2. Document map
**Program:** this file.
**Program:** this file (architecture / shared contract / build order).
**Progress tracker (Waves 02):** [`2026-07-24-driver-expansion-tracking.md`](2026-07-24-driver-expansion-tracking.md)
— per-deliverable status + links to each design doc and implementation plan.
**Research reports** (`docs/research/drivers/`, index: [`README.md`](../research/drivers/README.md)):
- [`mtconnect-agent.md`](../research/drivers/mtconnect-agent.md) ·
@@ -0,0 +1,170 @@
# Driver-expansion — Waves 02 tracking
> **Living status tracker** for the driver-expansion program (Waves 0, 1, 2). One row per
> deliverable, each pointing at its **design doc**, its **implementation plan** (once written),
> and its current **status**. The authoritative *architecture* index is the program design doc;
> this file is the authoritative *progress* index.
>
> **Program design (architecture / shared contract / build order):**
> [`2026-07-15-driver-expansion-program-design.md`](2026-07-15-driver-expansion-program-design.md)
>
> Last updated: 2026-07-24.
## Legend
| Status | Meaning |
|---|---|
| ✅ **Done** | Merged to master, tests green. |
| 🟡 **Live gate open** | Code merged; a live `/run` or hardware-gated verification still outstanding. |
| 📝 **Plan ready** | Executable implementation plan (`*-implementation.md` + `.tasks.json`) written; not yet built. |
| 📐 **Design only** | Design doc exists; **no** implementation plan yet — writing-plans is the next step. |
| ⛔ **Not started** | No design, no plan. |
**Doc types** (per the writing-plans skill): a *design* states architecture, decisions, and risks;
an *implementation plan* is the bite-sized, TDD, file-path-level task list the subagent-driven
executor runs off, with a co-located `.tasks.json` for resume. A deliverable is only buildable once
it reaches 📝.
## Summary
| Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware |
|---|---|---|---|---|---|---|
| **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | 🟡 **Live gate open** | SM | none (retrofits shipped drivers) |
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 🟢 **Built — live gate PASSED** (11 tasks, branch `feat/modbus-rtu-driver`, pending merge) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
| **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | 📝 **Plan ready** (22 tasks) | SM | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | 📝 **Plan ready** (23 tasks) | SM | Dockerized `mtconnect/cppagent` — CI-simulatable |
| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | 📝 **Plan ready** (27 tasks, P1+P2) | ML | Mosquitto/EMQX + C# Sparkplug sim — CI-simulatable |
> Waves 3+ (BACnet/IP, Omron) and the deferred MELSEC are tracked in the program design doc §6/§8,
> not here. Omron CIP is the program's sole real-hardware wire gate; BACnet's broadcast/BBMD leg is
> env-gated live. Both are out of this file's scope until they're pulled forward.
---
## Executing a plan (git worktree + subagent-per-task)
Each 📝 plan is executed with the **subagent-driven-development** skill: it sets up an isolated
**git worktree** first (via `using-git-worktrees`), then dispatches a **fresh subagent per task**,
running the classification-driven review chain (`trivial` = implement only … `high-risk` =
spec-review → code-review → integration review) between tasks. The `.tasks.json` next to each plan
tracks progress and lets a later session resume.
**Paste one of these into Claude Code to build a driver:**
| Deliverable | Command |
|---|---|
| Modbus RTU (Wave 1) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-modbus-rtu-driver.md in a new git worktree` |
| SQL poll (Wave 1) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-sql-poll-driver.md in a new git worktree` |
| MTConnect (Wave 2) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-mtconnect-driver.md in a new git worktree` |
| MQTT/Sparkplug (Wave 2) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-mqtt-sparkplug-driver.md in a new git worktree` |
- **Slash-command form** (equivalent): `/superpowers-extended-cc:subagent-driven-development <plan-path>`.
- **Parallel-session / resume form** (batch execution with checkpoints, no fresh-subagent-per-task):
`/superpowers-extended-cc:executing-plans <plan-path>` — reads the same `.tasks.json` and continues
from the first pending task.
- **Recommended order:** Modbus RTU → SQL poll → MTConnect → MQTT/Sparkplug (lowest effort first;
see each wave below for the gating notes). Run one plan per worktree; the four plans are
independent, so separate worktrees may run concurrently.
---
## Wave 0 — Universal Discover-backed browser 🟡
**What it is.** One generic `DiscoveryDriverBrowser` (+ `CapturingAddressSpaceBuilder`,
`CapturedTreeBrowseSession`, `BrowserSessionService` fallback, and the
`ITagDiscovery.SupportsOnlineDiscovery` gate) that turns any driver's `ITagDiscovery.DiscoverAsync`
into an AdminUI browse tree. It is the **Wave-0 gate**: every browsable new driver depends on this
seam, and it retrofits browse to the already-shipped AbCip / TwinCAT / FOCAS drivers for near-zero
marginal cost.
- **Design:** [`2026-07-15-universal-discovery-browser-design.md`](2026-07-15-universal-discovery-browser-design.md)
- **Implementation plan:** [`2026-07-15-universal-discovery-browser-implementation.md`](2026-07-15-universal-discovery-browser-implementation.md) · [`.tasks.json`](2026-07-15-universal-discovery-browser-implementation.md.tasks.json)
- **Status: 🟡 code-complete + merged, live gate open.**
- Merged to master — `056887d6` (*Merge feat/universal-discovery-browser — Wave-0 universal Discover-backed browser*), 2026-07-15.
- Implementation plan: **19 / 19 tasks completed.**
- Lit up AbCip / TwinCAT / FOCAS pickers with zero per-driver browse code.
- **Outstanding:** the full tree-render live `/run` gate is **fixture-blocked** — tracked as **Gitea #468**. Complete this before leaning on browse in Wave 2/3.
---
## Wave 1 — low-effort / high-leverage pair 📝
Both are **fully CI-simulatable with zero new hardware**; Modbus RTU needs no new infra at all, and
SQL poll reuses the always-on central SQL Server. This is the recommended next build.
### Modbus RTU — 🟢 Built, live gate PASSED (branch `feat/modbus-rtu-driver`, pending merge)
- **Design:** [`2026-07-15-modbus-rtu-driver-design.md`](2026-07-15-modbus-rtu-driver-design.md)
- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk), all complete via subagent-driven-development.
- **Scope:** extend the existing Modbus driver with a `Transport` selector (RTU CRC-16 + FC-aware
length, no TxId) + a `rtu_over_tcp` `pymodbus` docker profile + the AdminUI selector.
**Direct-serial transport is descoped** (user, 2026-07-15) — RTU-over-TCP via a serial→Ethernet
gateway is the only shipped mode, so there is no serial hardware gate.
- **Fixture:** added the `rtu_over_tcp` profile (`framer=rtu`, host `:5021`, co-runs with `standard`)
to the Modbus integration fixture. **Unknown confirmed:** pymodbus 3.13's simulator DOES honour
`framer=rtu` on a TCP server (wire-probed a pure RTU FC03 response, no MBAP) — so the simple profile
path was taken; **no stdlib fallback server was needed.**
- **Verification (all green):** unit suite **344/344**; full solution build 0 errors; the 2 RTU
integration tests pass **live** against the real `framer=rtu` fixture (read HR5→5, write+readback
HR200←4242). Per-task review chain (spec + code reviewers) + a final whole-branch review, all
approved-to-merge.
- **Live `/run` gate — PASSED (2026-07-24, docker-dev rebuilt from this branch):**
- AdminUI `/raw` Modbus **driver** config modal renders the new **Transport** selector (with the
operator hint) — set to `RtuOverTcp`, saved.
- **Device Test Connect → `OK · 23 MS`** — the probe built a `ModbusRtuOverTcpTransport` via the
factory and spoke **FC03 over real RTU framing** to `10.100.0.35:5021`.
- Authored raw tags HR5 (r/o) + HR200 (r/w) → **deployment `06ec1632` Sealed** (all 6 nodes acked).
- Client.CLI on `opc.tcp://localhost:4840` (`multi-role`/`password`): **read** `ns=2;s=rtu-gate/Device1/HR5`
**5, Good**; **write** `ns=2;s=rtu-gate/Device1/HR200` = 4242 → readback **4242, Good**.
- **Effort:** **S — the lowest on the roadmap.** Delivered first, as recommended.
### SQL poll — 📝 Plan ready
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks**. `ISqlDialect` seam in from day one; only the SQL Server dialect built in v1 (Postgres/ODBC deferred).
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server P1;
Postgres/ODBC land in P2/P3 behind `ISqlDialect`).
- **Fixture:** SQLite unit fixture (primary) + an **env-gated integration fixture against the
existing central SQL Server** (`10.100.0.35,14330`) with a seeded `SqlPollFixture` DB. The
blackhole/timeout live-gate pauses a **dedicated** `mssql` container — **never** the shared
central SQL Server (it hosts `ConfigDb`).
- **Effort:** SM.
---
## Wave 2 — strategic telemetry / UNS pair 📝
Both CI-simulatable on the shared docker host, no hardware.
### MTConnect Agent — 📝 Plan ready
- **Design:** [`2026-07-15-mtconnect-driver-design.md`](2026-07-15-mtconnect-driver-design.md)
- **Implementation plan:** [`2026-07-24-mtconnect-driver.md`](2026-07-24-mtconnect-driver.md) · [`.tasks.json`](2026-07-24-mtconnect-driver.md.tasks.json) — **23 tasks**. Task 0 is the TrakHound-vs-hand-rolled client decision; browse-picker live-verify is gated on Wave-0 #468.
- **Scope:** P1 Agent MVP (`IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+probe+rediscover),
browse **free via the Wave-0 universal browser** (`SupportsOnlineDiscovery=true`, no browser code),
typed editor, `UNAVAILABLE→BadNoCommunication` mapping, ring-buffer re-baseline paging.
- **Fixture:** canned XML unit fixtures (bulk of coverage) + a dockerized `mtconnect/cppagent`
integration fixture (env-gated). Depends on Wave 0's browse seam being live-verified (#468).
- **Effort:** SM (≈11.5 wk with TrakHound, ≈2.53 wk hand-rolled).
### MQTT / Sparkplug B — 📝 Plan ready
- **Design:** [`2026-07-15-mqtt-sparkplug-driver-design.md`](2026-07-15-mqtt-sparkplug-driver-design.md)
- **Implementation plan:** [`2026-07-24-mqtt-sparkplug-driver.md`](2026-07-24-mqtt-sparkplug-driver.md) · [`.tasks.json`](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) — **27 tasks** (P1 plain MQTT = Tasks 014, a complete shippable milestone; P2 Sparkplug B = Tasks 1526). Task 0 is the mandatory MQTTnet-5/net10 + central-pinning validation spike.
- **Scope:** P1 plain MQTT (MQTTnet-5 connect/TLS/auth + hand-rolled reconnect, subscribe→
`OnDataChange`, retained last-value read, `#`-observation browser); P2 Sparkplug B ingest
(vendored Tahu proto, birth/alias/seq-gap/rebirth state machine, death→STALE).
- **Fixture:** Mosquitto/EMQX broker (TLS + real auth, not anonymous) + a **project-owned C#
Sparkplug edge-node simulator** for the rebirth/seq-gap/death test matrix + env-gated live suite
(`MQTT_FIXTURE_ENDPOINT`).
- **Effort:** ML. **Top risks:** Sparkplug state-machine correctness; MQTTnet-5/net10 + the repo's
fragile central pinning (mitigated by hand-rolling Tahu over one MQTTnet-5 client).
---
## Next actions
1. **Close the Wave-0 live gate** (Gitea #468) — unblocks browse verification for MTConnect/BACnet.
2. **Build Wave 1** — Modbus RTU first (lowest effort, no new infra), then SQL poll. Both plans are
📝 ready; run the command from the [Executing a plan](#executing-a-plan-git-worktree--subagent-per-task)
table (subagent-driven-development in a git worktree).
3. **Build Wave 2** (MTConnect, then MQTT/Sparkplug) — both plans 📝 ready. MTConnect's browse leg
wants #468 closed first; MQTT's Task 0 pinning spike gates everything after it.
Update this file's Summary table and per-wave status whenever a deliverable changes state.
+729
View File
@@ -0,0 +1,729 @@
# Modbus RTU Driver Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Add 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 — reusing every codec/planner/health/materialisation surface unchanged.
**Architecture:** 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). Work = a CRC-16 helper + a length-less FC-aware framer + one new socket transport that composes a socket lifecycle extracted from `ModbusTcpTransport` + a transport-mode selector on the config + one AdminUI field. **Zero** changes to codecs/planner/health.
**Tech Stack:** existing `ZB.MOM.WW.OtOpcUa.Driver.Modbus` (+ `.Contracts`, `.Addressing`), xUnit + Shouldly, `pymodbus[simulator]==3.13.0` Docker fixture (RTU-framed-TCP profile; stdlib fallback server modelled on `exception_injector.py`), Blazor `ModbusDriverForm.razor` (AdminUI). No new NuGet dependency.
**Source design:** docs/plans/2026-07-15-modbus-rtu-driver-design.md
**Program design:** docs/plans/2026-07-15-driver-expansion-program-design.md (§3 shared contract, §7 fixtures) — this is **Wave 1**.
**Progress tracker:** docs/plans/2026-07-24-driver-expansion-tracking.md
## Scope (P1 shippable v1 only)
RTU-over-TCP via a serial→Ethernet gateway (Moxa/Digi/Lantronix), RTU CRC-16 (poly `0xA001`) + FC-aware length framing, a `Transport` selector (`Tcp` | `RtuOverTcp`) on the Modbus config, the `rtu_over_tcp` pymodbus docker profile, and the AdminUI selector. Read **and** write supported (same as TCP).
## Deferred / out of scope
- **Direct-serial transport** (`ModbusRtuTransport` / `System.IO.Ports`) — descoped (user, 2026-07-15). RTU buses are reached exclusively via a serial→Ethernet gateway. Design record kept in **design §2b / §9 P2**; the `ModbusRtuFraming` built here is byte-stream-agnostic so a future serial leg would reuse it unchanged. Do **not** number-squat an `Rtu` enum member for it.
- **Modbus ASCII** — a third framing (`:`-delimited hex + LRC); would be another `IModbusTransport` if ever needed (design §2c). Not planned.
- **Per-tag `TagConfig` changes** — none. `ModbusTagDefinition`/`ModbusTagDto` already carry the per-tag `UnitId` override that makes an RS-485 multi-drop bus work; the read planner already refuses to coalesce across UnitIds. Additions are **driver-level only** (design §4).
- **`ModbusDriver.BuildSlaveHostName` change** — NOT needed. It already emits `host:port/unit{n}`, the correct per-slave resilience key for a gateway (design §6). The design §1 table listed it only for the descoped serial (COM) case.
## Cross-cutting rules (program §3.1 — mandatory)
- **Enum-serialization trap** — `Transport` MUST round-trip as a **name string**, never a number, on both the AdminUI serializer (`ModbusDriverForm._jsonOpts` already carries `JsonStringEnumConverter`) and the factory (DTO field typed `string?`, parsed via the existing `ParseEnum<T>` — mirror `Family`/`MelsecSubFamily`; **do not** type the DTO field as the enum). Guarded by an explicit `"transport":"RtuOverTcp"` string assertion (design §5).
- **Per-op deadline (R2-01)** — the RTU transport MUST run every transaction under a linked-CTS `CancelAfter(Options.Timeout)` — a frozen gateway must never wedge a poll. There is **no MBAP length field to trust**; FC-aware sizing is the only length signal, so getting it right (or timing out) is the top correctness risk (design §7).
- **Single-flight mandatory on RTU** — no TxId means at most one transaction on the bus at a time. Keep the `_gate` `SemaphoreSlim` in the new transport.
- **Ctor is connection-free** — the transport constructor must not connect; all I/O happens in `ConnectAsync`/`SendAsync`.
- **Live-verify discipline** — the picker/editor + deploy path get a docker-dev `/run` before trust (Task 10).
---
## Task 0: Add the `ModbusTransportMode` enum
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1, Task 3
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTransportMode.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportModeTests.cs`
The enum lives in `.Contracts` (backend-dep-free), namespace `ZB.MOM.WW.OtOpcUa.Driver.Modbus` — same namespace as `ModbusDriverOptions`.
**Step 1 — failing test:**
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportModeTests
{
[Fact]
public void Tcp_is_the_default_zero_member()
=> ((ModbusTransportMode)0).ShouldBe(ModbusTransportMode.Tcp);
[Fact]
public void Exactly_two_members_ship_in_v1()
=> Enum.GetNames<ModbusTransportMode>().ShouldBe(["Tcp", "RtuOverTcp"]);
}
```
**Step 2 — run & expect FAIL** (type does not exist / does not compile):
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportModeTests"`
**Step 3 — minimal impl:**
```csharp
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Wire transport for a Modbus driver instance. <see cref="Tcp"/> = Modbus/TCP (MBAP + TxId);
/// <see cref="RtuOverTcp"/> = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to
/// a serial→Ethernet gateway. Default <see cref="Tcp"/> — existing configs omit the field.
/// A direct-serial <c>Rtu</c> member is intentionally NOT reserved here (descoped 2026-07-15);
/// do not number-squat it.
/// </summary>
public enum ModbusTransportMode
{
/// <summary>Modbus/TCP — 7-byte MBAP header + transaction id (the historical default).</summary>
Tcp,
/// <summary>RTU framing (<c>[addr][PDU][CRC-16]</c>) tunnelled over a socket to a serial→Ethernet gateway.</summary>
RtuOverTcp,
}
```
**Step 4 — run & expect PASS.**
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusTransportMode enum (Tcp | RtuOverTcp)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 1: `ModbusCrc` — CRC-16 (poly 0xA001) helper + golden vectors
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0, Task 3
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusCrc.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCrcTests.cs`
CRC-16/MODBUS: reflected poly `0xA001`, init `0xFFFF`, appended **low byte first**. This is the top-of-stack of the length-less framer — golden vectors are the whole point.
**Step 1 — failing test** (table-driven against published Modbus CRC vectors):
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusCrcTests
{
// Known CRC-16/MODBUS vectors (init 0xFFFF, poly 0xA001). CRC returned as ushort;
// on the wire it is appended low-byte-first.
[Theory]
// FC03 read HR: unit 1, addr 0, qty 1 -> CRC 0x0A84 (bytes 84 0A on the wire)
[InlineData(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 }, (ushort)0x0A84)]
// "123456789" canonical check value for CRC-16/MODBUS = 0x4B37
[InlineData(new byte[] { 0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39 }, (ushort)0x4B37)]
public void Compute_matches_known_vectors(byte[] frame, ushort expected)
=> ModbusCrc.Compute(frame).ShouldBe(expected);
[Fact]
public void AppendLowByteFirst_writes_lo_then_hi()
{
var body = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
var withCrc = ModbusCrc.Append(body);
withCrc.Length.ShouldBe(body.Length + 2);
withCrc[^2].ShouldBe((byte)0x84); // CRC low byte
withCrc[^1].ShouldBe((byte)0x0A); // CRC high byte
}
}
```
**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusCrcTests"`
**Step 3 — minimal impl** (`ModbusCrc.cs`): a `static ushort Compute(ReadOnlySpan<byte>)` (init `0xFFFF`, xor byte, 8× shift-right, xor `0xA001` on carry) and `static byte[] Append(ReadOnlySpan<byte> body)` returning `body + [crc & 0xFF, crc >> 8]`. ~30 lines. If a golden vector disagrees, fix the CRC math — never the vector.
**Step 4 — run & expect PASS.**
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusCrc CRC-16/MODBUS helper + golden vectors
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 2: `ModbusRtuFraming` — ADU build + FC-aware length-less deframe
**Classification:** high-risk (framing)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuFramingTests.cs`
The one genuinely new correctness risk (design §2a/§7): RTU frames carry **no length field**, so response size is parsed from the function code. Read `addr(1)`+`fc(1)`, then: **exception** (`fc & 0x80`) → `excCode(1)+CRC(2)`; **reads FC01/02/03/04**`byteCount(1)` then `byteCount + CRC(2)`; **write echoes FC05/06/15/16** → fixed `4 + CRC(2)`. Validate trailing CRC, strip `addr`+`CRC`, return the bare PDU `[fc, ...data]`. CRC mismatch / truncation → `ModbusTransportDesyncException`; exception PDU (after CRC-validate) → `ModbusException`. Test directly against an in-memory `MemoryStream` of canned bytes.
**Step 1 — failing test:**
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusRtuFramingTests
{
private static MemoryStream Canned(params byte[] frame) => new(frame);
[Fact]
public void BuildAdu_prefixes_unit_and_appends_crc()
{
var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 });
adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
}
[Fact]
public async Task ReadResponse_FC03_returns_bare_pdu()
{
// addr=01 fc=03 byteCount=02 data=00 0A + CRC(lo,hi)
var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A };
var frame = ModbusCrc.Append(body);
await using var s = Canned(frame);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
}
[Fact]
public async Task ReadResponse_exception_frame_throws_ModbusException()
{
// addr=01 fc=0x83 exc=0x02 + CRC
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
await using var s = Canned(frame);
var ex = await Should.ThrowAsync<ModbusException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.FunctionCode.ShouldBe((byte)0x03);
ex.ExceptionCode.ShouldBe((byte)0x02);
}
[Fact]
public async Task ReadResponse_bad_crc_throws_desync()
{
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
frame[^1] ^= 0xFF; // corrupt CRC high byte
await using var s = Canned(frame);
await Should.ThrowAsync<ModbusTransportDesyncException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
}
[Fact]
public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu()
{
// addr=01 fc=06 addr=00 07 value=00 2A + CRC -> PDU = 06 00 07 00 2A
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A });
await using var s = Canned(frame);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A });
}
}
```
**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusRtuFramingTests"`
**Step 3 — minimal impl** (`ModbusRtuFraming.cs`): `static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)` = `ModbusCrc.Append([unitId, ..pdu])`; `static async Task<byte[]> ReadResponsePduAsync(Stream, byte expectedUnit, CancellationToken)` doing the FC-aware read described above with a `ReadExactlyAsync` helper (mirror `ModbusTcpTransport.ReadExactlyAsync`). Reuse `ModbusException` + `ModbusTransportDesyncException` (both already in the project). Keep framing byte-stream-agnostic (no socket knowledge) so a future serial transport can reuse it.
**Step 4 — run & expect PASS.**
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusRtuFraming (ADU build + FC-aware length-less deframe)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 3: Extract `ModbusSocketLifecycle` from `ModbusTcpTransport` (behaviour-preserving)
**Classification:** high-risk (transport / concurrency)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0, Task 1, Task 2
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusSocketLifecycle.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusSocketLifecycleTests.cs`
Mechanical refactor: move the IPv4-preference connect (`ConnectAsync`), `EnableKeepAlive`/`ClampToWholeSeconds`, `ConnectWithBackoffAsync`, `TearDownAsync`, and the `_lastSuccessUtc`/idle-disconnect tracking out of `ModbusTcpTransport` into a reusable `ModbusSocketLifecycle` that exposes the current `NetworkStream`. `ModbusTcpTransport` keeps its MBAP `SendOnceAsync` and composes the lifecycle. **Behaviour must be byte-for-byte unchanged** — the existing `ModbusTcpReconnectTests` + `ModbusConnectionOptionsTests` are the regression net; run the whole Modbus.Tests suite green before committing.
**Step 1 — failing test** (pins the extracted surface; `ClampToWholeSeconds` moves with it):
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusSocketLifecycleTests
{
[Theory]
[InlineData(0.4, 1)] // sub-second rounds up to 1 (the int-cast truncation guard)
[InlineData(2.0, 2)]
[InlineData(-5.0, 1)] // negative clamps to 1
public void ClampToWholeSeconds_rounds_up_min_one(double seconds, int expected)
=> ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
[Fact]
public async Task Connect_to_dead_port_surfaces_socket_failure()
{
var life = new ModbusSocketLifecycle("127.0.0.1", 1, TimeSpan.FromMilliseconds(300),
autoReconnect: false);
await Should.ThrowAsync<System.Net.Sockets.SocketException>(
life.ConnectAsync(TestContext.Current.CancellationToken));
}
}
```
**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusSocketLifecycleTests"`
**Step 3 — minimal impl:** create `ModbusSocketLifecycle` holding `_host/_port/_timeout/_autoReconnect/_keepAlive/_idleDisconnect/_reconnect`, the `TcpClient`/`NetworkStream`, and `_lastSuccessUtc`; expose `ConnectAsync`, `ConnectWithBackoffAsync`, `TearDownAsync`, `Stream` (current), `MarkSuccess()`, `ShouldReconnectForIdle()`, and the `static int ClampToWholeSeconds`. Re-point `ModbusTcpTransport` to delegate connect/reconnect/teardown/idle to it while keeping MBAP `SendOnceAsync` in place. Move (don't duplicate) `ClampToWholeSeconds` — update its one internal caller/test reference.
**Step 4 — run & expect PASS** — then run the **whole** suite to prove no regression:
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests`
**Step 5 — commit:**
`git commit -am "refactor(modbus): extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 4: `ModbusRtuOverTcpTransport` — RTU framing over the socket lifecycle
**Classification:** high-risk (framing / transport / concurrency)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuOverTcpTransport.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuOverTcpTransportTests.cs`
`IModbusTransport` implementation: composes `ModbusSocketLifecycle` (Task 3) for connect/reconnect/keepalive/idle and `ModbusRtuFraming` (Task 2) for send/receive. Delta from `ModbusTcpTransport`: CRC framing instead of MBAP, **no TxId**. Keep the `_gate` `SemaphoreSlim` single-flight (mandatory — no TxId), the single reconnect-retry shape, and a linked-CTS `CancelAfter(Options.Timeout)` per-op deadline (design §7, R2-01). **Seam choice for unit test:** add an `internal` test ctor accepting a pre-connected `Stream` (an in-memory duplex fake) that bypasses `ConnectAsync` — this is "the fake one level below the `IModbusTransport` fakes" the design §8 calls for, and lets the send/receive orchestration + single-flight + deadline be tested with no socket.
**Step 1 — failing test** (duplex fake: canned response stream + capture of written request bytes):
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusRtuOverTcpTransportTests
{
[Fact]
public async Task SendAsync_writes_rtu_adu_and_returns_deframed_pdu()
{
// Response the fake gateway will emit: FC03, 1 reg = 0x000A.
var response = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
var fake = new CapturingDuplexStream(response);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
var pdu = await transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
// The request went out as an RTU ADU: unit + pdu + CRC, NO MBAP header.
fake.Written.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
}
[Fact]
public async Task SendAsync_exception_pdu_surfaces_ModbusException()
{
var response = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
var fake = new CapturingDuplexStream(response);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
await Should.ThrowAsync<ModbusException>(
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken));
}
[Fact]
public async Task SendAsync_stalled_gateway_hits_per_op_deadline()
{
var fake = new CapturingDuplexStream(respondBytes: Array.Empty<byte>(), stall: true);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200));
await Should.ThrowAsync<ModbusTransportDesyncException>(
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken));
}
}
```
(The `CapturingDuplexStream` test double — a `Stream` that records writes and replays `respondBytes` on read, or blocks forever when `stall` — lives in the test file.)
**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusRtuOverTcpTransportTests"`
**Step 3 — minimal impl:** production ctor takes the same connection params as `ModbusTcpTransport` and builds a `ModbusSocketLifecycle`; `internal static ForTest(Stream, TimeSpan)` injects a pre-connected stream. `SendAsync`: `_gate.WaitAsync`; idle-reconnect check (skipped in test seam); write `ModbusRtuFraming.BuildAdu(unitId, pdu)`, flush, `ModbusRtuFraming.ReadResponsePduAsync(...)` under a linked `CancelAfter(_timeout)`; timeout-vs-caller-cancel distinction + teardown mirror `ModbusTcpTransport.SendOnceAsync`; single reconnect-retry on socket-level failure.
**Step 4 — run & expect PASS.**
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 5: `ModbusTransportFactory.Create` — switch on `Transport`
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 6
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportFactory.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportFactoryTests.cs`
One place that maps `ModbusDriverOptions` → the right `IModbusTransport`, consumed by both the driver default closure (Task 7) and the probe (Task 7). `Tcp``ModbusTcpTransport`, `RtuOverTcp``ModbusRtuOverTcpTransport`; both get `Host/Port/Timeout/AutoReconnect/KeepAlive/IdleDisconnect/Reconnect`.
**Step 1 — failing test:**
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportFactoryTests
{
[Fact]
public void Tcp_mode_builds_tcp_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.Tcp })
.ShouldBeOfType<ModbusTcpTransport>();
[Fact]
public void RtuOverTcp_mode_builds_rtu_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
[Fact]
public void Default_options_build_tcp_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions())
.ShouldBeOfType<ModbusTcpTransport>();
}
```
(Depends on Task 6's `Transport` property existing on `ModbusDriverOptions` — sequence Task 6 or add the property first. If Task 5 lands before Task 6, add the property in this task instead.)
**Step 2 — run & expect FAIL.**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportFactoryTests"`
**Step 3 — minimal impl:** `static IModbusTransport Create(ModbusDriverOptions o)` = `switch (o.Transport) { RtuOverTcp => new ModbusRtuOverTcpTransport(...), _ => new ModbusTcpTransport(...) }`, passing the shared connection params.
**Step 4 — run & expect PASS.**
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusTransportFactory switch on Transport mode
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 6: Add `Transport` to options + DTO + factory (string-enum guard)
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportConfigRoundTripTests.cs`
Add `public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;` to `ModbusDriverOptions`. On the DTO: add `public string? Transport { get; init; }` (typed **`string?`**, per the Modbus factory pattern — design §5) and map it with the existing `ParseEnum<ModbusTransportMode>(...)` helper, defaulting to `Tcp` when null. The guard test asserts the string wire form.
**Step 1 — failing test:**
```csharp
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportConfigRoundTripTests
{
// Mirrors the AdminUI ModbusDriverForm serializer: camelCase + string enums.
private static readonly JsonSerializerOptions _adminOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
[Fact]
public void Transport_serializes_as_a_string_not_a_number()
{
var opts = new ModbusDriverOptions { Host = "10.20.0.50", Port = 4001, Transport = ModbusTransportMode.RtuOverTcp };
var json = JsonSerializer.Serialize(opts, _adminOpts);
json.ShouldContain("\"transport\":\"RtuOverTcp\"");
json.ShouldNotContain("\"transport\":1", Case.Sensitive);
}
[Fact]
public void Factory_binds_RtuOverTcp_from_string_config()
{
const string json = """{ "host": "10.20.0.50", "port": 4001, "transport": "RtuOverTcp" }""";
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-rtu", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
}
[Fact]
public void Factory_defaults_Transport_to_Tcp_when_omitted()
{
const string json = """{ "host": "10.0.0.10" }""";
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-tcp", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Transport.ShouldBe(ModbusTransportMode.Tcp);
}
}
```
**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportConfigRoundTripTests"`
**Step 3 — minimal impl:** add the `Transport` property (options) + DTO `string?` field + factory mapping `Transport = dto.Transport is null ? ModbusTransportMode.Tcp : ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport")`.
**Step 4 — run & expect PASS.**
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): bind Transport mode on options/DTO/factory (string-enum guard)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 7: Wire the driver default closure + probe to `ModbusTransportFactory`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 8
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportWiringTests.cs`
Replace the driver ctor's hardcoded `new ModbusTcpTransport(...)` default closure with `o => ModbusTransportFactory.Create(o)`. In the probe, replace the hardcoded `new ModbusTcpTransport(host, port, timeout, autoReconnect: false)` (line ~77) with a factory build carrying `Transport` (parse it into `ProbeTarget` from the DTO), `autoReconnect: false`. Now an `RtuOverTcp`-authored config probes over RTU framing — matching how it will actually run.
**Step 1 — failing test** (the default closure honours the mode; the probe carries it):
```csharp
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportWiringTests
{
private static IModbusTransport BuildDefaultTransport(ModbusDriverOptions opts)
{
using var driver = new ModbusDriver(opts, "wire-test");
var factory = (Func<ModbusDriverOptions, IModbusTransport>)typeof(ModbusDriver)
.GetField("_transportFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
return factory(opts);
}
[Fact]
public void Default_closure_builds_rtu_transport_for_RtuOverTcp()
=> BuildDefaultTransport(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
[Fact]
public void Default_closure_builds_tcp_transport_for_Tcp()
=> BuildDefaultTransport(new ModbusDriverOptions())
.ShouldBeOfType<ModbusTcpTransport>();
}
```
(If `_transportFactory` reflection proves brittle, assert instead that a `RtuOverTcp` driver initialised against an unreachable host degrades without ever constructing an MBAP frame — but the closure-reflection test is the tightest.)
**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportWiringTests"`
**Step 3 — minimal impl:** driver ctor default = `?? (o => ModbusTransportFactory.Create(o))`; probe: add `ModbusTransportMode Transport` to `ProbeTarget`, parse `dto.Transport` via `ParseEnum`, and build the probe transport through `ModbusTransportFactory.Create(new ModbusDriverOptions { Host, Port, Timeout, Transport, AutoReconnect = false })`.
**Step 4 — run & expect PASS** (+ run the full Modbus.Tests suite green).
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): route driver + probe transports through ModbusTransportFactory
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 8: AdminUI — `Transport` selector on `ModbusDriverForm`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs`
Add a `Transport` `<InputSelect>` to the **Protocol** panel (design's target `ModbusDriverPage.razor` is retired; `ModbusDriverForm.razor` is the live host — it serializes `ModbusDriverOptions` directly through `_jsonOpts`, which already carries `JsonStringEnumConverter`, so `Transport` emits as a name string automatically; `Transport` is **not** an endpoint key so it is not stripped by `GetConfigJson`). Add `public ModbusTransportMode Transport { get; set; } = ModbusTransportMode.Tcp;` to `FormModel`, map it in `FromOptions`/`ToOptions`. Host/Port stay on `ModbusDeviceForm` — a `RtuOverTcp` gateway uses the same `host:port` shape. Extend the existing `ModbusDriverFormModelTests` (round-trip + string-enum guard).
**Step 1 — failing test** (append to `ModbusDriverFormModelTests`):
```csharp
[Fact]
public void Round_trip_preserves_Transport_mode()
{
var form = new ModbusDriverForm.FormModel { Transport = ModbusTransportMode.RtuOverTcp };
var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
var back = ModbusDriverForm.FormModel.FromOptions(
JsonSerializer.Deserialize<ModbusDriverOptions>(json, JsonOpts)!);
back.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
json.ShouldContain("\"transport\":\"RtuOverTcp\""); // name string, never a number
}
```
**Step 2 — run & expect FAIL:**
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ModbusDriverFormModelTests"`
**Step 3 — minimal impl:** add the `Transport` `FormModel` property + `FromOptions`/`ToOptions` mapping, and an `<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync">` looping `Enum.GetValues<ModbusTransportMode>()` in the Protocol panel, with a form-text hint: "RtuOverTcp = talk raw RTU frames to a serial→Ethernet gateway; must match the gateway's mode."
**Step 4 — run & expect PASS.** (AdminUI has no bUnit — the razor binding itself is proven live in Task 10; this model test is the reflection substitute per the AdminUI live-verify memory.)
**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add Transport selector to the AdminUI Modbus driver form
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 9: Docker `rtu_over_tcp` fixture + RTU-over-TCP integration test
**Classification:** standard (new component / multi-file)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/rtu_over_tcp.json`
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpFixture.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpTests.cs`
- Modify (only if the pymodbus RTU-framer confirmation fails): `Docker/Dockerfile` + `Docker/rtu_over_tcp_server.py`
**FIRST sub-step — confirm the unknown (design §8 #1):** verify the pymodbus simulator actually exposes the RTU framer on a TCP server. The checked-in `profiles/standard.json` already sets `server_list.srv.framer: "socket"`, so the knob exists; the RTU profile sets `"framer": "rtu"` (comm stays `"tcp"`). Bring it up and probe with the new driver:
```bash
lmxopcua-fix sync modbus
lmxopcua-fix up modbus rtu_over_tcp
# then run the integration test (below) against MODBUS_RTU_SIM_ENDPOINT=10.100.0.35:5021
```
If pymodbus 3.13's simulator does **not** honour `framer=rtu` on a TCP server (garbled/MBAP-framed replies), **fall back** to a stdlib `rtu_over_tcp_server.py` modelled on the existing `exception_injector.py` (same asyncio TCP-server shape, but frame with `[addr][PDU][CRC-16]` via a Python CRC-16/MODBUS instead of the MBAP header) + a `COPY rtu_over_tcp_server.py` line in the Dockerfile and a `command:` override on the service. Record which path was taken in the test-class summary.
Fixture details: bind host port **`5021`** (NOT the shared `:5020` — the `rtu_over_tcp` service must co-run with `standard` and sidestep the shared-port stale-container trap). Add `labels: { project: lmxopcua }` per the program fixture convention (existing services carry none — `lmxopcua-fix sync` normally owns the label host-side, but add it in-file for this service so it is discoverable). `ModbusRtuOverTcpFixture` mirrors `ModbusSimulatorFixture` but reads `MODBUS_RTU_SIM_ENDPOINT` (default `10.100.0.35:5021`) with the same skip-clean pattern.
**Step 1 — failing test** (`ModbusRtuOverTcpTests.cs` — read + write round-trip over RTU-over-TCP):
```csharp
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
[Collection(ModbusRtuOverTcpCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "RtuOverTcp")]
public sealed class ModbusRtuOverTcpTests(ModbusRtuOverTcpFixture sim)
{
[Fact]
public async Task Reads_a_seeded_holding_register_over_rtu_framing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var opts = new ModbusDriverOptions
{
Host = sim.Host, Port = sim.Port, UnitId = 1,
Transport = ModbusTransportMode.RtuOverTcp,
Timeout = TimeSpan.FromSeconds(2),
Probe = new ModbusProbeOptions { Enabled = false },
RawTags = ModbusRawTags.Entries([
new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, Address: 5,
DataType: ModbusDataType.UInt16, Writable: false),
]),
};
await using var driver = new ModbusDriver(opts, "modbus-rtu-int");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u); // Good
results[0].Value.ShouldBe((ushort)5); // HR[5] seeded = address-as-value
}
[Fact]
public async Task Writes_and_reads_back_a_holding_register_over_rtu_framing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var opts = new ModbusDriverOptions
{
Host = sim.Host, Port = sim.Port, UnitId = 1,
Transport = ModbusTransportMode.RtuOverTcp,
Timeout = TimeSpan.FromSeconds(2),
Probe = new ModbusProbeOptions { Enabled = false },
RawTags = ModbusRawTags.Entries([
new ModbusTagDefinition("HR200", ModbusRegion.HoldingRegisters, Address: 200,
DataType: ModbusDataType.UInt16, Writable: true),
]),
};
await using var driver = new ModbusDriver(opts, "modbus-rtu-int-w");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var writes = await driver.WriteAsync([new WriteRequest("HR200", (ushort)4242)],
TestContext.Current.CancellationToken);
writes[0].StatusCode.ShouldBe(0u);
var back = await driver.ReadAsync(["HR200"], TestContext.Current.CancellationToken);
back[0].Value.ShouldBe((ushort)4242);
}
}
```
(Seed `rtu_over_tcp.json` with the same `HR[0..31]=address-as-value` + `HR[200..209]=scratch` layout as `standard.json` so these addresses resolve.)
**Step 2 — run & expect FAIL / SKIP** without the fixture, then bring it up:
```bash
lmxopcua-fix sync modbus && lmxopcua-fix up modbus rtu_over_tcp
MODBUS_RTU_SIM_ENDPOINT=10.100.0.35:5021 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests --filter "FullyQualifiedName~ModbusRtuOverTcpTests"
```
**Step 3 — minimal impl:** the `rtu_over_tcp.json` profile + compose service (+ stdlib server fallback only if the framer confirmation failed) + the fixture class. Iterate until both facts hold on the wire.
**Step 4 — run & expect PASS** against the live fixture.
**Step 5 — commit:**
`git commit -am "test(modbus-rtu): add rtu_over_tcp pymodbus fixture + RTU-over-TCP integration tests
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Task 10: Live `/run` verification on docker-dev
**Classification:** standard (live gate)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify (docs only): `docs/plans/2026-07-24-driver-expansion-tracking.md` (record Wave-1 Modbus-RTU live-gate result)
No new code — the live-verify discipline (program §3.1) proves the Razor binding + deploy path that unit tests can't. Against the local docker-dev rig (AdminUI `http://localhost:9200`, login disabled — drive it yourself; do not wait for the user to sign in):
1. Bring up the `rtu_over_tcp` fixture (`lmxopcua-fix up modbus rtu_over_tcp`, host `:5021`).
2. In `/raw`, author a Modbus device pointing Host/Port at the docker host `:5021`, and set the driver **Transport = RtuOverTcp** in the driver-config modal (the Task 8 selector). Run **Test Connect** — expect green (the probe now runs FC03 over RTU framing).
3. Author a holding-register raw tag (e.g. `HR5`), reference it into a UNS equipment, and deploy.
4. Read it back via Client.CLI against the local server, or the `/uns` value panel:
`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath-of-HR5>"`
— expect Good quality + the seeded value.
5. Write to a writable register (`HR200`) via Client.CLI `write` and confirm it round-trips.
6. Record PASS/FAIL + evidence in the tracking doc.
**Step 5 — commit:**
`git commit -am "docs(modbus-rtu): record Wave-1 RTU-over-TCP live /run gate result
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
---
## Ordering summary
Framer/CRC unit layer (Tasks 02) → transport (Tasks 34) → factory + config + driver/probe wiring (Tasks 57) → AdminUI selector (Task 8) → docker integration fixture (Task 9) → live `/run` (Task 10). Tasks 1 & 3 can run alongside Task 0; Task 5 alongside Task 6; Task 7 alongside Task 8. Commit after every task. DRY: `ModbusRtuFraming` and `ModbusSocketLifecycle` are each written once and composed; no codec/planner/health code is touched. YAGNI: no direct-serial, no ASCII, no per-tag config, no `BuildSlaveHostName` change.
@@ -0,0 +1,17 @@
{
"planPath": "docs/plans/2026-07-24-modbus-rtu-driver.md",
"tasks": [
{"id": 0, "subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)", "status": "pending"},
{"id": 1, "subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors", "status": "pending"},
{"id": 2, "subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)", "status": "pending"},
{"id": 4, "subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)", "status": "pending", "blockedBy": [2, 3]},
{"id": 5, "subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode", "status": "pending", "blockedBy": [0, 4]},
{"id": 6, "subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)", "status": "pending", "blockedBy": [0]},
{"id": 7, "subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory", "status": "pending", "blockedBy": [5, 6]},
{"id": 8, "subject": "Task 8: AdminUI Transport selector on ModbusDriverForm", "status": "pending", "blockedBy": [6]},
{"id": 9, "subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test", "status": "pending", "blockedBy": [4, 7]},
{"id": 10, "subject": "Task 10: Live /run verification on docker-dev", "status": "pending", "blockedBy": [8, 9]}
],
"lastUpdated": "2026-07-24"
}
@@ -0,0 +1,969 @@
# MQTT / Sparkplug B Driver Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Ship a standard Equipment-kind `Mqtt` driver that ingests plain MQTT (P1) then Sparkplug B (P2) into the OtOpcUa dual-namespace address space, with a bespoke observation browser, typed AdminUI editor, and TLS+auth-enforced fixtures.
**Architecture:** Three new `net10.0` projects mirror the OpcUaClient triad — `.Contracts` (transport-free options/DTOs/parser/enums), `.Driver` (subscribe-first `IDriver` holding one live MQTTnet-5 client for its whole lifetime, raising `OnDataChange` from the receive callback, with a hand-rolled reconnect loop since v5 dropped `ManagedMqttClient`), and `.Browser` (a passive `#`/birth observation-window `IBrowseSession`). Sparkplug B decodes vendored Eclipse Tahu protobuf via `Google.Protobuf`/`Grpc.Tools` over that same single client — no SparkplugNet.
**Tech Stack:** MQTTnet v5 (MIT, .NET Foundation, ships `net10.0`), vendored Eclipse Tahu `sparkplug_b.proto` + `Google.Protobuf` (already pinned 3.34.1) + `Grpc.Tools` (already pinned 2.76.0, build-time), bespoke browser.
**Source design:** docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md
**Phases:** P1 (plain MQTT) = Tasks 014 (a complete shippable milestone); P2 (Sparkplug B ingest) = Tasks 1526 (built on the P1 skeleton). Write-through (NCMD/DCMD/plain-publish) is deferred — see "Deferred / out of scope".
---
## Cross-cutting rules (apply to every task — program design §3.1)
- **Enum-serialization trap (systemic bug):** every JSON seam that (de)serializes `MqttDriverOptions` or a tag config — factory, probe, browser, **and** the AdminUI driver-config page + probe DTO — MUST share a `JsonSerializerOptions` carrying `new JsonStringEnumConverter()` + `PropertyNameCaseInsensitive=true` + `UnmappedMemberHandling=Skip`. `MqttMode`, `MqttPayloadFormat`, `protocolVersion` are enums; serialize them as **names**. Mirror `OpcUaClientDriverFactoryExtensions`.
- **Per-op deadline (R2-01 frozen-peer lesson):** every network call (connect / subscribe / probe / rebirth-publish) has a bounded linked-CTS deadline. No unbounded waits on a dead broker.
- **Ctor is connection-free:** `MqttDriver`/`MqttDriverBrowser` constructors touch no network; all connects happen in `InitializeAsync`/`OpenAsync` (the universal-browser `CanBrowse` throwaway-instance pattern depends on this).
- **Secrets from env:** broker `password` is blank in committed JSON, supplied via env (mirror `ServerHistorian__ApiKey`). Never commit or log creds.
- **Broker security — never ship anonymous/public-broker defaults:** default `useTls=true`, real auth. `allowUntrustedServerCertificate` + `caCertificatePath` mirror the ServerHistorian TLS knobs (dev/on-prem escape hatch, off by default). Fixtures run auth+TLS.
- **DriverType string is `"Mqtt"`** everywhere (driver page, probe, factory, editor map, validator, browser). One constant `DriverTypeNames.Mqtt`; grep to enforce (heed the `ModbusTcp`/`Modbus` mismatch lesson).
- **Live-verify discipline:** Razor binding + deploy-inertness bugs pass unit tests. Every picker/editor/deploy path gets a docker-dev `/run` live-verify (Tasks 14 + 26).
- **New-project csproj:** `net10.0`, `Nullable` + `ImplicitUsings` enabled, `TreatWarningsAsErrors=true` opted in per-csproj (not global).
---
## Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (this is the gate — the design's #1 dependency risk; nothing else starts until it is green)
**Files:**
- Modify: `Directory.Packages.props` (add `<PackageVersion Include="MQTTnet" Version="5.x" />`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (temporary spike form — references `MQTTnet` only to prove the graph restores; the transport ref is removed in Task 1, `.Contracts` is transport-free)
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the project)
**Why first:** The design (§2.1, §10) makes this the top dependency risk — the repo uses central package management with `CentralPackageTransitivePinningEnabled` **deliberately OFF** (Roslyn 5.0.0/4.12.0 split, `Directory.Packages.props:103` comment + "Transitive pinning breaks Roslyn build" memory). We must prove **MQTTnet v5 restores and builds clean on net10 in this exact pinning configuration** before writing any driver code. Fresh-restore red-vs-local-green (the NU1903 memory) also means we validate a clean restore, not just an incremental one.
### Steps
1. Confirm on NuGet the exact latest **MQTTnet v5** version carrying a `net10.0` TFM; pin that exact version in `Directory.Packages.props` (alphabetical position near `MessagePack`).
2. Scaffold the `.Contracts` csproj (net10.0, nullable, implicit usings, TWAE) with a single `<PackageReference Include="MQTTnet" />` (no `Version` — central management supplies it). Add to `slnx`.
3. Re-verify the two external-library record claims (design §2.1 checkbox): (a) MQTTnet v5 net10.0 TFM exists; (b) SparkplugNet still transitively pins MQTTnet 4.3.x with no net10.0 TFM. Record the confirmed versions in the tasks.json note. The hand-roll decision does not hinge on (b) — it is a confirm-the-record check.
4. Prove a **clean** restore + build:
```bash
dotnet restore src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: no NU1605/NU1608 version-conflict, no NU1903 audit error
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: Build succeeded, 0 warnings (TWAE on)
rm -rf ~/.nuget/packages/mqttnet && dotnet restore src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # fresh-restore proof
```
**Expected:** all three succeed with zero version-conflict / transitive-pin / audit errors. If restore fails on a 4/5 diamond or a missing net10 TFM, STOP — the hand-roll-Tahu decision (§2.1) is vindicated but the MQTTnet-5 pin itself is the blocker; resolve the exact version before proceeding.
5. Commit:
```bash
git commit -am "feat(mqtt): validate MQTTnet-5/net10 restore under central pinning (spike, P1 gate)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"
```
---
## Task 1 (P1): `.Contracts` — enums + `MqttDriverOptions` DTO
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 2 depends on this)
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (drop the temporary MQTTnet ref from Task 0 — `.Contracts` is transport-free; reference only `Core.Abstractions`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttMode.cs` (`Plain | SparkplugB`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttPayloadFormat.cs` (`Json | Raw | Scalar`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttProtocolVersion.cs` (`V311 | V500`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs` (broker conn + mode + `sparkplug`/`plain` sub-objects, §5.1)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj` (net10, xUnit + Shouldly)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
### Steps (TDD)
1. Write the failing test — options round-trip enum-as-name:
```csharp
public sealed class MqttDriverOptionsTests
{
private static readonly JsonSerializerOptions J = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
[Fact]
public void Deserialize_SparkplugConfig_ReadsModeAndSubObject()
{
const string json = """
{ "host":"10.100.0.35","port":8883,"useTls":true,"mode":"SparkplugB",
"sparkplug":{"groupId":"Plant1","hostId":"h1","requestRebirthOnGap":true} }
""";
var o = JsonSerializer.Deserialize<MqttDriverOptions>(json, J)!;
o.Mode.ShouldBe(MqttMode.SparkplugB);
o.UseTls.ShouldBeTrue();
o.Sparkplug!.GroupId.ShouldBe("Plant1");
o.Plain.ShouldBeNull();
}
[Fact]
public void Serialize_WritesEnumsAsNames_NotOrdinals()
{
var s = JsonSerializer.Serialize(new MqttDriverOptions { Mode = MqttMode.Plain }, J);
s.ShouldContain("\"Plain\"");
s.ShouldNotContain("\"mode\":0");
}
}
```
2. Run — expect FAIL (types don't exist):
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests # RED
```
3. Implement the enums + `MqttDriverOptions` (with nested `MqttSparkplugOptions` / `MqttPlainOptions`) matching §5.1 defaults (`useTls=true`, `connectTimeoutSeconds=15`, `reconnectMin/MaxBackoffSeconds=1/30`). Wire the test csproj into `slnx`.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): Contracts options DTO + enums (name-serialized)"` (+ session trailer).
---
## Task 2 (P1): `.Contracts` — `MqttTagDefinition` + `MqttEquipmentTagParser` (plain)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 6 depends on the resolver)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs` (parsed per-tag descriptor — carries plain OR sparkplug variant fields; plain fields populated in P1)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs` (`TryParse(reference, out def)` + `Inspect(reference)`, mirrors `ModbusEquipmentTagParser` / `ModbusTagDefinitionFactory`)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs`
### Steps (TDD)
1. Failing tests — parse a plain TagConfig blob, reject a typo'd enum (strict), and reject a wildcard topic:
```csharp
[Fact]
public void TryParse_PlainJsonBlob_PopulatesTopicAndPath()
{
const string r = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Double","qos":1}""";
MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue();
def!.Topic.ShouldBe("factory/oven/temp");
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
def.Name.ShouldBe(r); // the def Name == reference string (forward-router key)
}
[Fact]
public void TryParse_TypoedPayloadFormat_RejectsStrict()
=> MqttEquipmentTagParser.TryParse(
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}""", out _).ShouldBeFalse();
[Fact]
public void Inspect_WildcardTopic_ReturnsWarning()
=> MqttEquipmentTagParser.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}""").ShouldNotBeEmpty();
```
2. Run — expect FAIL.
3. Implement: a leading `{` marks a TagConfig blob; use `TagConfigJson.TryReadEnumStrict` for `payloadFormat`/`dataType` (typo → `false``BadNodeIdUnknown` upstream). `def.Name = reference`. `Inspect` returns a deploy-time warning list for present-but-invalid enums / unparseable blobs / wildcard tag-topics. Leave Sparkplug-descriptor parsing as a stub the P2 tasks fill (`groupId`/`edgeNodeId`/`deviceId`/`metricName`).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): plain tag parser + strict-enum descriptor"` (+ trailer).
---
## Task 3 (P1): `.Driver` project + `MqttConnection` connect/TLS/auth (bounded)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 4 extends `MqttConnection`)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj` (net10, refs `.Contracts` + `Core.Abstractions` + `Core` + `MQTTnet`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (MQTTnet-5 client wrapper: build options from `MqttDriverOptions` incl. TLS + CA-pin + credentials; `ConnectAsync(ct)` under `connectTimeoutSeconds` linked-CTS)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
### Steps (TDD)
1. Failing test — bounded connect against a dead endpoint fails fast (no hang), and TLS-option assembly honours the knobs. Use a closed loopback port for the deadline test:
```csharp
[Fact]
public async Task ConnectAsync_DeadBroker_FailsWithinDeadline_NoHang()
{
var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 2 };
var conn = new MqttConnection(opts, driverId: "t", logger: null);
var sw = Stopwatch.StartNew();
await Should.ThrowAsync<Exception>(() => conn.ConnectAsync(CancellationToken.None));
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5)); // deadline honoured, not wedged
}
[Fact]
public void BuildClientOptions_UseTlsWithCaPin_SetsTlsAndValidatesChain()
{
var opts = new MqttDriverOptions { Host="h", Port=8883, UseTls=true,
AllowUntrustedServerCertificate=false, CaCertificatePath="/tmp/ca.pem" };
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions.UseTls.ShouldBeTrue();
}
```
2. Run — expect FAIL.
3. Implement `MqttConnection`: static `BuildClientOptions(opts, clientIdSuffix)` (host/port, protocol version, clientId + optional suffix, keep-alive, clean-session, credentials, TLS with `AllowUntrustedServerCertificate` → custom cert validator, `CaCertificatePath` → chain pin). `ConnectAsync(ct)` links `ct` with a `connectTimeoutSeconds` CTS. Ctor stores options only — connection-free.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): MqttConnection connect/TLS/auth under bounded deadline"` (+ trailer).
---
## Task 4 (P1): `MqttConnection` hand-rolled reconnect loop (backoff + resubscribe)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (reconnect loop: exponential backoff `min→max`; on `DisconnectedAsync` schedule reconnect; on reconnect fire a `Reconnected` callback so the driver re-subscribes; expose `State` = Connected/Reconnecting/Faulted + `LastMessageAgeUtc`)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs`
**Why high-risk:** MQTTnet v5 dropped v4's `ManagedMqttClient` (§8) — there is no library-managed reconnect. Subscriptions do not survive a clean session; a reconnect that forgets to re-subscribe silently goes dark. Backoff that ignores its cap can hot-loop a dead broker.
### Steps (TDD)
1. Failing tests — backoff schedule is bounded + monotone-to-cap, and a `Reconnected` event drives a re-subscribe callback. Test the backoff calculator as a pure function (no live broker):
```csharp
[Theory]
[InlineData(0, 1)] [InlineData(1, 2)] [InlineData(2, 4)] [InlineData(10, 30)] // caps at max=30
public void NextBackoff_IsExponentialClampedToMax(int attempt, int expectedSeconds)
=> MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30)
.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
[Fact]
public async Task OnReconnect_InvokesResubscribeCallback()
{
var conn = new MqttConnection(new MqttDriverOptions(), "t", null);
var fired = 0; conn.Reconnected += () => { fired++; return Task.CompletedTask; };
await conn.RaiseReconnectedForTest(); // test seam
fired.ShouldBe(1);
}
```
2. Run — expect FAIL.
3. Implement: `NextBackoff(attempt, min, max)` pure; a reconnect worker that loops on disconnect with backoff, honours `cleanSession`/session-expiry, re-subscribes via the `Reconnected` callback (idempotent insurance even for persistent sessions), and (Sparkplug, P2) re-requests rebirth. `State` transitions Connected↔Reconnecting; Faulted only on unrecoverable config. Never block the MQTTnet dispatcher thread.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): hand-rolled reconnect loop with bounded backoff + resubscribe"` (+ trailer).
---
## Task 5 (P1): `LastValueCache` + `IReadable`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 6
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs` (`FullReference → DataValueSnapshot`, thread-safe)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/LastValueCacheTests.cs`
### Steps (TDD)
1. Failing test — unseen reference returns a per-ref `GoodNoData`/uncertain snapshot (never throws the batch); a seeded ref returns last value:
```csharp
[Fact]
public void Read_UnseenRef_ReturnsPerRefNoData_DoesNotThrow()
{
var c = new LastValueCache();
var snap = c.Read("factory/oven/temp#$.value");
snap.StatusCode.ShouldBe(StatusCodes.GoodNoData); // or Uncertain — per IReadable contract, per-ref status
}
[Fact]
public void Update_ThenRead_ReturnsLastValue()
{
var c = new LastValueCache();
c.Update("k", DataValueSnapshot.Good(42.0, DateTime.UtcNow));
c.Read("k").Value.ShouldBe(42.0);
}
```
2. Run — expect FAIL.
3. Implement `LastValueCache` (concurrent dict); `MqttDriver.ReadAsync` (Task 7) returns `references.Select(cache.Read)` — batch never throws, per-ref `StatusCode` carries "not yet observed".
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): last-value cache backing IReadable (per-ref no-data)"` (+ trailer).
---
## Task 6 (P1): `ISubscribable` — plain topic subscribe → `OnDataChange` + retained seed
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs` (registers `FullReference → MqttTagDefinition` via the shared `EquipmentTagRefResolver<MqttTagDefinition>`; dedupes/coalesces topics; on message: match topic → authored tag(s), extract value at `jsonPath`/raw/scalar, raise `OnDataChange`; seed from retained message on subscribe)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (expose `SubscribeAsync(filters, ct)` under a bounded deadline + a message-received event)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs`
### Steps (TDD)
1. Failing test — feed a synthetic received message through the manager (no live broker); assert routing + JSONPath extraction fire `OnDataChange` with the right `FullReference`:
```csharp
[Fact]
public void OnMessage_MatchingTopic_RaisesDataChangeAtJsonPath()
{
var mgr = new MqttSubscriptionManager();
var handle = mgr.Register(new[] { """{"topic":"f/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Double"}""" });
string? gotRef = null; object? gotVal = null;
mgr.OnDataChange += (_, e) => { gotRef = e.FullReference; gotVal = e.Snapshot.Value; };
mgr.HandleMessage("f/oven/temp", """{"value":21.5}"""u8.ToArray(), retained: false);
gotVal.ShouldBe(21.5);
gotRef.ShouldContain("f/oven/temp");
}
[Fact]
public void OnMessage_UnauthoredTopic_RaisesNothing() // chatty broker must not auto-provision
{
var mgr = new MqttSubscriptionManager();
mgr.Register(Array.Empty<string>());
var fired = false; mgr.OnDataChange += (_, _) => fired = true;
mgr.HandleMessage("random/topic", "1"u8.ToArray(), retained: false);
fired.ShouldBeFalse();
}
```
2. Run — expect FAIL.
3. Implement using `EquipmentTagRefResolver<MqttTagDefinition>` (parse-once cache, as Modbus does). `HandleMessage` matches topic → tag(s), extracts (Json→JSONPath token→typed value; Raw→bytes-as-string; Scalar→parse), updates `LastValueCache`, raises `OnDataChange`. Retained-flagged messages seed initial value. `SubscribeAsync` returns an `ISubscriptionHandle` whose `DiagnosticId` names mode+filter; completes under a bounded SUBACK deadline (SUBACK failure → per-ref Bad, not hang).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): plain subscribe→OnDataChange with retained seed + ref resolver"` (+ trailer).
---
## Task 7 (P1): `MqttDriver` shell — `IDriver` + authored-only `ITagDiscovery` + `IHostConnectivityProbe`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 8/9 wire it)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (`IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable`; composes `MqttConnection` + `MqttSubscriptionManager` + `LastValueCache`)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs`
### Steps (TDD)
1. Failing test — `DiscoverAsync` streams **only authored tags** into a capturing `IAddressSpaceBuilder`; plain-mode `RediscoverPolicy == Once`; `SupportsOnlineDiscovery == false`:
```csharp
[Fact]
public async Task DiscoverAsync_Plain_StreamsAuthoredTagsOnly_PolicyOnce()
{
var driver = new MqttDriver(new MqttDriverOptions { Mode = MqttMode.Plain }, "d", null);
driver.SetAuthoredTagsForTest(new[] { """{"topic":"f/t","payloadFormat":"Raw","dataType":"String"}""" });
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Count.ShouldBe(1);
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
}
```
2. Run — expect FAIL.
3. Implement: `InitializeAsync` deserializes options (shared `JsonSerializerOptions`), builds + connects `MqttConnection`, subscribes the mode-appropriate filter. `ReinitializeAsync` applies deltas in place (never crash → Faulted). `ShutdownAsync` disconnects/disposes. `GetHealth` = Connected/Reconnecting/Faulted + last-message-age. `GetMemoryFootprint` = caches; `FlushOptionalCachesAsync` = no-op (birth/alias are correctness state — forbidden to flush; last-value backs `IReadable`). `DiscoverAsync` replays authored tags only; `RediscoverPolicy => Once` (plain). `IRediscoverable.OnRediscoveryNeeded` never fires in plain mode.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): MqttDriver shell — lifecycle + authored-only discovery (Once)"` (+ trailer).
---
## Task 8 (P1): `MqttDriverProbe` — CONNECT handshake
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs` (`IDriverProbe`, `DriverType => "Mqtt"`; parse config with the shared options, open a CONNECT under the timeout, green + latency on CONNACK-accepted, targeted error on refused/TLS/auth/timeout — mirrors `ModbusDriverProbe`)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs`
### Steps (TDD)
1. Failing test — `DriverType` is `"Mqtt"`; a dead endpoint yields a failed (not thrown) probe result within the deadline:
```csharp
[Fact]
public void DriverType_IsCanonicalMqtt() => new MqttDriverProbe().DriverType.ShouldBe("Mqtt");
[Fact]
public async Task ProbeAsync_DeadBroker_ReturnsFailedResult_WithinDeadline()
{
var r = await new MqttDriverProbe().ProbeAsync(
"""{"host":"127.0.0.1","port":1,"useTls":false,"connectTimeoutSeconds":2}""", CancellationToken.None);
r.Success.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
}
```
2. Run — expect FAIL.
3. Implement; CONNACK-accepted is the MQTT "device is answering" proof. Use the **shared** `JsonSerializerOptions` (enum-as-name).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): MqttDriverProbe CONNECT handshake"` (+ trailer).
---
## Task 9 (P1): Factory + `DriverTypeNames.Mqtt` + Host registration
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs` (`DriverTypeName = "Mqtt"`, shared `JsonSerializerOptions`, `Register(registry, loggerFactory)` — mirror `OpcUaClientDriverFactoryExtensions`)
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs` (add `public const string Mqtt = "Mqtt";` + append to the `All` list)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs` (add `Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory);` in `Register(...)` near line 146; add `using MqttProbe = Driver.Mqtt.MqttDriverProbe;` + `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());` in `AddOtOpcUaDriverProbes` near line 124)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` (ProjectReference the `.Driver` assembly)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs`
### Steps (TDD)
1. Failing test — `Register` binds the `"Mqtt"` type, and the factory builds an `MqttDriver` from JSON with string enums:
```csharp
[Fact]
public void Register_ThenCreate_BuildsMqttDriver()
{
var registry = new DriverFactoryRegistry();
MqttDriverFactoryExtensions.Register(registry);
var d = registry.Create("Mqtt", "d1", """{"host":"h","port":1883,"mode":"Plain"}""");
d.ShouldBeOfType<MqttDriver>();
}
[Fact]
public void DriverTypeName_MatchesConstant()
=> MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt);
```
2. Run — expect FAIL.
3. Implement + wire both host sites. Build the whole solution to confirm registration compiles:
```bash
dotnet build ZB.MOM.WW.OtOpcUa.slnx # expect: Build succeeded
```
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): factory + DriverTypeNames.Mqtt + host factory/probe registration"` (+ trailer).
---
## Task 10 (P1): `.Browser` — bespoke `#`-observation browser (passive)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 8
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj` (net10, refs `.Contracts` + `Commons(.Browsing)` + `MQTTnet`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs` (`IDriverBrowser`, `DriverType => "Mqtt"`; `OpenAsync` connects with a `{clientId}-browse-{guid8}` suffix under a clamped 530 s budget; subscribes `#`/`{topicPrefix}#`; returns `MqttBrowseSession`; **publishes nothing**)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs` (`IBrowseSession` over a thread-safe accumulating observed tree; `RootAsync`/`ExpandAsync` split topic segments on `/`, leaf = `Kind=Leaf`; `AttributesAsync` = synthetic attribute w/ inferred type + last payload snippet; `DisposeAsync` disconnects best-effort)
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs`
### Steps (TDD)
1. Failing test — feed observed topics into the session's accumulating tree (test seam, no live broker); assert `RootAsync`/`ExpandAsync` build the segment tree and **no publish** occurs on any browse call:
```csharp
[Fact]
public async Task ExpandAsync_BuildsTopicSegmentTree_FromObservedTopics()
{
var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/line3/oven/temp");
var root = await s.RootAsync(CancellationToken.None);
root.ShouldContain(n => n.BrowseName == "factory");
var lvl2 = await s.ExpandAsync("factory", CancellationToken.None);
lvl2.ShouldContain(n => n.BrowseName == "line3");
}
[Fact]
public async Task BrowseCalls_PublishNothing() // browse is read-only
{
var s = new MqttBrowseSession(MqttMode.Plain);
await s.RootAsync(CancellationToken.None);
s.PublishCountForTest.ShouldBe(0);
}
```
2. Run — expect FAIL.
3. Implement the accumulating tree + passive `OpenAsync`. In P1 only the plain `#` path is live; leave a Sparkplug hook the P2 tasks fill (`Group→EdgeNode→Device→Metric` + `RequestRebirthAsync`).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): bespoke passive #-observation browser (plain)"` (+ trailer).
---
## Task 11 (P1): Register the bespoke browser (overrides universal fallback)
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 12
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (add `services.AddSingleton<IDriverBrowser, MqttDriverBrowser>();` alongside the existing two near line 7576)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (ProjectReference `.Browser`)
### Steps
1. Add the registration + project reference. Registering for `DriverType="Mqtt"` overrides the universal fallback (`BrowserSessionService` resolves bespoke-first).
2. Build: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` — expect success.
3. Commit: `git commit -am "feat(mqtt): register MqttDriverBrowser (bespoke-first for Mqtt)"` (+ trailer).
---
## Task 12 (P1): Typed AdminUI editor + validator (plain shape)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 11
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs` (thin typed model over a preserved `JsonObject` key bag; `FromJson`/`ToJson`/`Validate`, preserves unknown keys incl. history keys; carries a `Mode` field selecting sub-shape — P1 implements Plain `Validate`, Sparkplug stub filled in Task 24)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (`[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor)`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs` (`[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate()`)
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor` (copy the Modbus editor template — mode-switch at top, per-mode field group, client-side `Validate()`)
- Create: `tests/Server/.../MqttTagConfigModelTests.cs` (co-locate with the existing AdminUI test project — verify path with `find tests/Server -name "*TagConfigModel*Tests.cs"`)
### Steps (TDD)
1. Failing test — round-trip preserves unknown/history keys; plain `Validate` requires concrete topic + jsonPath-when-Json; re-derives `FullName`:
```csharp
[Fact]
public void FromJson_ToJson_PreservesUnknownKeys()
{
var m = MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","isHistorized":true}""");
m.ToJson().ShouldContain("isHistorized");
}
[Fact]
public void Validate_PlainWildcardTopic_Fails()
=> MqttTagConfigModel.FromJson("""{"topic":"a/+/c","payloadFormat":"Raw","dataType":"String"}""")
.Validate().ShouldNotBeEmpty();
[Fact]
public void Validate_JsonWithoutPath_Fails()
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""")
.Validate().ShouldNotBeEmpty();
```
2. Run — expect FAIL.
3. Implement the model (mirror `OpcUaClientTagConfigModel`); `ToJson` writes PascalCase `FullName` re-derived from descriptor (`{topic}#{jsonPath}` plain). Build the `.razor`. Register both map entries.
4. Run — expect PASS + `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI`.
5. Commit: `git commit -am "feat(mqtt): typed tag editor + validator (plain mode)"` (+ trailer).
---
## Task 13 (P1): Mosquitto + JSON-publisher fixture (TLS+auth) + env-gated live suite
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests.csproj`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` (`eclipse-mosquitto` with **auth + TLS** on `:8883`, plain `:1883` for smoke only; a JSON-publisher container emitting `retain=true` JSON on a few topics — `mosquitto_pub` loop or `paho-mqtt` script; `project=lmxopcua` label applied host-side)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/mosquitto.conf` + password/cert material generator script (creds via env, never real secrets committed)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/PlainMqttLiveTests.cs` (category `LiveIntegration`, gated on `MQTT_FIXTURE_ENDPOINT` — skips clean when unset → macOS-offline-safe)
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
### Steps
1. Write the compose with **auth + TLS** (never anonymous). Add the JSON publisher with `retain=true`.
2. Write env-gated tests: connect/TLS/auth; plain subscribe + retained-read seed; unauthored-topic silence. `[SkippableFact]` reading `MQTT_FIXTURE_ENDPOINT`.
3. Confirm offline skip:
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests # expect: all Skipped (no env var)
```
4. Deploy the fixture to the docker host and run live (design §9):
```bash
lmxopcua-fix sync mqtt && lmxopcua-fix up mqtt
export MQTT_FIXTURE_ENDPOINT=10.100.0.35:8883
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests --filter "Category=LiveIntegration" # expect: PASS
```
5. Commit: `git commit -am "test(mqtt): Mosquitto TLS+auth fixture + env-gated plain live suite"` (+ trailer).
---
## Task 14 (P1): Live `/run` verify on docker-dev — **P1 MILESTONE COMPLETE**
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `CLAUDE.md` (add the MQTT fixture endpoint to the Docker Workflow endpoint list: `10.100.0.35:1883/8883`, `MQTT_FIXTURE_ENDPOINT`)
- Modify: `docs/plans/2026-07-15-driver-expansion-tracking.md` (mark MQTT P1 code-complete)
### Steps
1. On docker-dev (`:9200`, login disabled — run it yourself, do not defer): author an `Mqtt` driver (Plain mode) + a tag via the `/uns` picker (bespoke `#`-observation browser) or the typed editor; deploy.
2. Confirm via Client.CLI the node carries live broker values:
```bash
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>"
```
3. Confirm the typed editor renders (Razor binding bugs pass unit tests — live-verify is mandatory). Update CLAUDE.md + tracking doc.
4. Commit: `git commit -am "docs(mqtt): P1 plain-MQTT milestone live-verified; endpoint recorded"` (+ trailer).
---
# ── P2: Sparkplug B ingest (builds on the P1 skeleton) ──
## Task 15 (P2): Vendor Tahu proto + `Grpc.Tools` codegen
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (P2 root)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Protos/sparkplug_b.proto` (vendored Eclipse Tahu `sparkplug_b.proto`, pinned to a known Tahu commit with a provenance comment header)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (add `Google.Protobuf` + build-time `Grpc.Tools` (`PrivateAssets=all`); `<Protobuf Include="Protos/sparkplug_b.proto" GrpcServices="None" />` — message-only codegen, this repo's first in-repo protoc step)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugProtoCodegenTests.cs`
### Steps (TDD)
1. Failing test — the generated `Payload`/`Metric` types exist and round-trip a hand-built payload:
```csharp
[Fact]
public void GeneratedPayload_RoundTrips()
{
var p = new Org.Eclipse.Tahu.Protobuf.Payload { Seq = 3 };
p.Metrics.Add(new Org.Eclipse.Tahu.Protobuf.Payload.Types.Metric { Name = "Temperature", Alias = 5 });
var back = Org.Eclipse.Tahu.Protobuf.Payload.Parser.ParseFrom(p.ToByteArray());
back.Seq.ShouldBe(3ul);
back.Metrics[0].Alias.ShouldBe(5ul);
}
```
2. Run — expect FAIL (no generated types).
3. Vendor the proto (provenance comment: Tahu commit hash + URL); wire `Grpc.Tools`. If in-repo protoc proves objectionable, fall back to checking in the generated C# with the same provenance comment (design §2.1). Build:
```bash
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: proto compiles, 0 warnings
```
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): vendor Tahu sparkplug_b.proto + Grpc.Tools codegen"` (+ trailer).
---
## Task 16 (P2): `SparkplugCodec` decode + golden payloads
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 17
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugCodec.cs` (decode `Payload`/`Metric` from wire bytes → a driver-side struct; encode NCMD deferred to `RebirthRequester` Task 20 / write-through P3)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/nbirth.bin` + `ndata.bin` (golden byte vectors, generated once from a hand-built payload and committed)
### Steps (TDD)
1. Failing test — decode a golden NBIRTH: seq + metric name/alias/datatype/value survive:
```csharp
[Fact]
public void Decode_GoldenNbirth_ExtractsMetrics()
{
var payload = SparkplugCodec.Decode(File.ReadAllBytes("Golden/nbirth.bin"));
payload.Seq.ShouldBe((byte)0);
payload.Metrics.ShouldContain(m => m.Name == "Temperature" && m.Alias == 5 && m.DataType == SparkplugDataType.Float);
}
```
2. Run — expect FAIL.
3. Implement `SparkplugCodec.Decode`; generate the golden vectors in a one-off `[Fact(Skip="generator")]` or a small helper, commit the `.bin` files.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): SparkplugCodec decode + golden payload vectors"` (+ trailer).
---
## Task 17 (P2): `SparkplugTopic` + `SparkplugDataType.ToDriverDataType`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugTopic.cs` (parse/format `spBv1.0/{group}/{type}/{node}[/{device}]`; `type` ∈ NBIRTH/DBIRTH/NDATA/DDATA/NDEATH/DDEATH/NCMD/DCMD/STATE)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/SparkplugDataType.cs` (enum + `ToDriverDataType()` per the §3.5 map: Int8→Int16, UInt8→UInt16, DataSet/Template unsupported, `*Array`→element+IsArray)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugTopicTests.cs`
### Steps (TDD)
1. Failing tests — topic parse extracts group/type/node/device; datatype map widens Int8→Int16 and marks DataSet unsupported:
```csharp
[Fact]
public void Parse_DeviceData_ExtractsAllSegments()
{
var t = SparkplugTopic.Parse("spBv1.0/Plant1/DDATA/EdgeA/Filler1");
t.GroupId.ShouldBe("Plant1"); t.Type.ShouldBe(SparkplugMessageType.DDATA);
t.EdgeNodeId.ShouldBe("EdgeA"); t.DeviceId.ShouldBe("Filler1");
}
[Theory]
[InlineData(SparkplugDataType.Int8, DriverDataType.Int16)]
[InlineData(SparkplugDataType.UInt8, DriverDataType.UInt16)]
[InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
public void ToDriverDataType_MapsAndWidens(SparkplugDataType s, DriverDataType d)
=> s.ToDriverDataType().ShouldBe(d);
```
2. Run — expect FAIL.
3. Implement.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): Sparkplug topic parse + datatype map"` (+ trailer).
---
## Task 18 (P2): `BirthCache` + `AliasTable` — bind-by-name, rebuild-per-birth
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 19
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs` (per `(edgeNode,device)` alias→(name,datatype); **rebuilt wholesale each birth, never merged**)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/BirthCache.cs` (NBIRTH/DBIRTH metric catalog: name/alias/datatype/last value)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/AliasBindingTests.cs`
**Why high-risk:** §3.6 invariants #1#2 — binding data by alias silently mis-routes when an alias is reused for a different metric across a rebirth. Bind by **stable metric NAME**; the alias is a per-birth cache only.
### Steps (TDD)
1. Failing tests — the two load-bearing invariants:
```csharp
[Fact]
public void Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias()
{
var t = new AliasTable();
t.RebuildFromBirth(new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float) });
t.Resolve(alias: 5).Name.ShouldBe("Temperature");
// rebirth: alias 5 now means a DIFFERENT metric
t.RebuildFromBirth(new[] { (name:"Pressure", alias:5ul, dt:SparkplugDataType.Float) });
t.Resolve(alias: 5).Name.ShouldBe("Pressure"); // wholesale replace, not merge
}
[Fact]
public void RebuildFromBirth_DoesNotMergeStaleAliases()
{
var t = new AliasTable();
t.RebuildFromBirth(new[] { (name:"A", alias:1ul, dt:SparkplugDataType.Int32) });
t.RebuildFromBirth(new[] { (name:"B", alias:2ul, dt:SparkplugDataType.Int32) });
t.TryResolve(alias: 1, out _).ShouldBeFalse(); // alias 1 gone after rebirth
}
```
2. Run — expect FAIL.
3. Implement — `RebuildFromBirth` replaces the whole map. `BirthCache` keeps the metric catalog + last value keyed by name.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): AliasTable/BirthCache — bind-by-name, rebuild-per-birth"` (+ trailer).
---
## Task 19 (P2): `SequenceTracker` — seq-gap + bdSeq death-tie
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 18
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs` (per edge-node `seq` 0255 wrap gap detection; `bdSeq` ties NDEATH↔NBIRTH)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SequenceTrackerTests.cs`
**Why high-risk:** §3.6 invariant #3 — a wrong wrap boundary (255→0 is NOT a gap) either misses real gaps (stale data) or false-positives every wrap (rebirth-storm).
### Steps (TDD)
1. Failing tests — contiguous ok, wrap ok, gap detected:
```csharp
[Fact]
public void Sequence_WrapAt255IsContiguous_NotAGap()
{
var s = new SequenceTracker();
s.Accept(254).ShouldBeTrue(); s.Accept(255).ShouldBeTrue(); s.Accept(0).ShouldBeTrue(); // wrap
}
[Fact]
public void Sequence_SkippedValue_IsGap()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(12).ShouldBeFalse(); // gap → caller requests rebirth
}
```
2. Run — expect FAIL.
3. Implement (next-expected = `(last+1) & 0xFF`); `bdSeq` compare helper.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): SequenceTracker seq-gap + bdSeq death-tie"` (+ trailer).
---
## Task 20 (P2): `RebirthRequester` — NCMD encode
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/RebirthRequester.cs` (encode an NCMD writing `Node Control/Rebirth = true` to `spBv1.0/{group}/NCMD/{node}`; publish under a bounded deadline)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/RebirthRequesterTests.cs`
### Steps (TDD)
1. Failing test — encoded NCMD carries the `Node Control/Rebirth`=true metric and targets the right topic:
```csharp
[Fact]
public void BuildRebirthNcmd_EncodesControlMetric_AndTopic()
{
var (topic, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
topic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
var p = SparkplugCodec.Decode(bytes);
p.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true));
}
```
2. Run — expect FAIL.
3. Implement (encode path via the generated proto).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): RebirthRequester NCMD encode"` (+ trailer).
---
## Task 21 (P2): Sparkplug ingest state machine — the §3.6 matrix
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (integrates Tasks 1620 into the driver)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs` (routes decoded messages: N/DBIRTH → rebuild `AliasTable` + `BirthCache`; N/DDATA → resolve alias→name → map to authored `FullReference` **by (group,node,device,metricName)**`OnDataChange`; N/DDEATH → emit STALE/Bad snapshots; seq-gap / unknown-alias / data-before-birth → `RebirthRequester` gated on `requestRebirthOnGap`; STATE/primary-host handling; late-join rebirth on connect)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (Sparkplug-mode subscribe `spBv1.0/{group}/#` (+ STATE) → `SparkplugIngestor`; reconnect → re-subscribe + request rebirth)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorTests.cs`
**Why high-risk:** this is the §3.6 correctness core — the #1 risk in the design. Test the full matrix: rebirth, missed-birth, seq-gap, alias-reuse-across-rebirth, death→stale→rebirth.
### Steps (TDD)
1. Failing tests — the §3.6 matrix, driven through the ingestor with synthetic decoded messages (no live broker):
```csharp
[Fact]
public void Birth_Then_Data_ResolvesByAlias_RaisesOnDataChangeByName()
{
var ing = new SparkplugIngestor(...); // authored tag: Plant1/EdgeA/Filler1:Temperature
string? firedRef = null; ing.OnDataChange += (_, e) => firedRef = e.FullReference;
ing.HandleDbirth("Plant1","EdgeA","Filler1", new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float, val:(object)0f) });
ing.HandleDdata("Plant1","EdgeA","Filler1", seq:1, new[] { (alias:5ul, val:(object)21.5f) });
firedRef.ShouldContain("Filler1:Temperature");
}
[Fact]
public void DataBeforeBirth_RequestsRebirth()
{
var ncmds = new List<string>();
var ing = new SparkplugIngestor(..., publishNcmd: (t,_) => ncmds.Add(t));
ing.HandleDdata("Plant1","EdgeA","Filler1", seq:0, new[] { (alias:9ul, val:(object)1f) });
ncmds.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
[Fact]
public void Ddeath_EmitsStaleForDeviceMetrics_NextBirthRestoresGood()
{
var ing = new SparkplugIngestor(...);
var quals = new List<StatusCode>(); ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
ing.HandleDbirth("Plant1","EdgeA","Filler1", new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float, val:(object)0f) });
ing.HandleDdeath("Plant1","EdgeA","Filler1");
quals.ShouldContain(q => StatusCode.IsBad(q)); // STALE/Bad on death
}
```
2. Run — expect FAIL.
3. Implement the ingestor holding the §3.6 invariants. Route via `EquipmentTagRefResolver` keyed by `(group,node,device,metricName)`.
4. Run — expect PASS (all matrix cases).
5. Commit: `git commit -am "feat(mqtt): Sparkplug ingest state machine (birth/alias/seq/rebirth/death→stale)"` (+ trailer).
---
## Task 22 (P2): `ITagDiscovery` `UntilStable` + `IRediscoverable`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (`RediscoverPolicy => UntilStable` in Sparkplug mode — authored tags' datatypes fill in as births arrive; `DiscoverAsync` re-streams authored tags with resolved datatypes from `BirthCache`; fire `OnRediscoveryNeeded` on a **new DBIRTH** or a rebirth with a changed metric set, `ScopeHint` = edge-node/device folder path)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs`
### Steps (TDD)
1. Failing tests — Sparkplug policy is `UntilStable`; a new DBIRTH fires `OnRediscoveryNeeded`; a tag authored before its birth picks up the birth datatype:
```csharp
[Fact]
public void SparkplugMode_RediscoverPolicy_IsUntilStable()
=> new MqttDriver(new MqttDriverOptions { Mode = MqttMode.SparkplugB }, "d", null)
.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable);
[Fact]
public async Task NewDbirth_FiresOnRediscoveryNeeded()
{
var d = new MqttDriver(new MqttDriverOptions { Mode = MqttMode.SparkplugB }, "d", null);
var fired = false; ((IRediscoverable)d).OnRediscoveryNeeded += (_, _) => fired = true;
d.SimulateNewDbirthForTest("Plant1","EdgeA","Filler2");
fired.ShouldBeTrue();
}
```
2. Run — expect FAIL.
3. Implement.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): Sparkplug UntilStable discovery + rediscover-on-DBIRTH"` (+ trailer).
---
## Task 23 (P2): Sparkplug browser tree + `AttributesAsync` + `RequestRebirthAsync`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 24
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs` (Sparkplug tree Group→EdgeNode→Device→Metric from observed births; `AttributesAsync` = self-describing metric `AttributeInfo{Name, DriverDataType from birth, IsArray, SecurityClass}`; `RequestRebirthAsync(scope)` — the **only** publishing session member, scoped to selected group/edge-node, via the `RebirthRequester` path)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs` (Sparkplug discovery filter `spBv1.0/{groupId}/#`; `OpenAsync` still publishes nothing)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs` (dispatch the MQTT-specific `RequestRebirthAsync` for MQTT sessions, gated by the same `DriverOperator` policy that gates the picker; Info-log the target scope)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs`
### Steps (TDD)
1. Failing tests — birth-driven tree fills; `OpenAsync`/`RootAsync`/`ExpandAsync` publish **nothing**; `RequestRebirthAsync` publishes exactly one scoped NCMD:
```csharp
[Fact]
public async Task SparkplugTree_FillsFromObservedBirths_BrowseNeverPublishes()
{
var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1","EdgeA","Filler1","Temperature", SparkplugDataType.Float);
var groups = await s.RootAsync(default);
groups.ShouldContain(n => n.BrowseName == "Plant1");
s.PublishCountForTest.ShouldBe(0); // passive
}
[Fact]
public async Task RequestRebirthAsync_ScopedToNode_PublishesOneNcmd()
{
var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1","EdgeA","Filler1","T", SparkplugDataType.Float);
await s.RequestRebirthAsync(scope: "Plant1/EdgeA");
s.PublishCountForTest.ShouldBe(1);
}
```
2. Run — expect FAIL.
3. Implement. `RequestRebirthAsync` is never fired by `OpenAsync`/`RootAsync`/`ExpandAsync` — only on explicit operator click.
4. Run — expect PASS + `dotnet build` the AdminUI.
5. Commit: `git commit -am "feat(mqtt): Sparkplug birth-driven browser tree + scoped Request-rebirth action"` (+ trailer).
---
## Task 24 (P2): AdminUI editor Sparkplug mode shape + validator
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 23
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs` (Sparkplug `Validate`: `groupId`/`edgeNodeId`/`metricName` required, `deviceId` optional, `dataType` a known Sparkplug type; `ToJson` re-derives `FullName` = `{group}/{node}[/{device}]:{metric}`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor` (Sparkplug field group under the mode switch)
- Modify: `tests/Server/.../MqttTagConfigModelTests.cs`
### Steps (TDD)
1. Failing tests — Sparkplug validation + FullName derivation:
```csharp
[Fact]
public void Validate_SparkplugMissingMetricName_Fails()
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA"}""").Validate().ShouldNotBeEmpty();
[Fact]
public void ToJson_Sparkplug_DerivesFullName()
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float"}""")
.ToJson().ShouldContain("Plant1/EdgeA/Filler1:Temperature");
```
2. Run — expect FAIL.
3. Implement.
4. Run — expect PASS + AdminUI build.
5. Commit: `git commit -am "feat(mqtt): typed tag editor Sparkplug mode + validation"` (+ trailer).
---
## Task 25 (P2): C# edge-node simulator fixture + §3.6 live matrix
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/` (a project-owned C# edge-node simulator using the **same** MQTTnet-5 + Tahu-proto path the driver uses — publishes NBIRTH/DBIRTH → periodic N/DDATA; honours a rebirth NCMD; injects seq-gap / alias-reuse / N/DDEATH on demand)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` (add the simulator container against the same TLS+auth Mosquitto)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugLiveTests.cs` (category `LiveIntegration`, env-gated: birth→data→alias-resolve→OnDataChange; rebirth recovery; death→STALE; browser passive window asserts **no NCMD published by open/browse**; explicit `RequestRebirthAsync` node-vs-group enumeration)
### Steps
1. Build the simulator (encode via the same generated proto — validates encode/decode symmetry).
2. Write env-gated live tests covering the §3.6 matrix + browser passivity.
3. Offline skip proof:
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests # expect: Skipped without MQTT_FIXTURE_ENDPOINT
```
4. Deploy + run live:
```bash
lmxopcua-fix sync mqtt && lmxopcua-fix up mqtt sparkplug
export MQTT_FIXTURE_ENDPOINT=10.100.0.35:8883
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests --filter "Category=LiveIntegration" # expect: PASS
```
5. Commit: `git commit -am "test(mqtt): C# Sparkplug edge-node simulator + §3.6 live matrix"` (+ trailer).
---
## Task 26 (P2): Live `/run` verify Sparkplug — **P2 COMPLETE**
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `docs/plans/2026-07-15-driver-expansion-tracking.md` (mark MQTT/Sparkplug P1+P2 code-complete + live-verified)
- Modify: `CLAUDE.md` (if a driver-fleet or endpoint fact changed; propagate to `../scadaproj/CLAUDE.md` OtOpcUa entry per the cross-repo rule)
### Steps
1. On docker-dev (`:9200`): author an `Mqtt` driver (SparkplugB mode) against the simulator; browse the birth-driven picker, click **Request rebirth**, pick a metric, deploy.
2. Confirm live values + rediscover-on-DBIRTH + death→STALE via Client.CLI reads/subscribe.
3. Confirm the typed editor renders both modes (live-verify — Razor bugs pass unit tests).
4. Update tracking + (if needed) CLAUDE.md/scadaproj index.
5. Commit: `git commit -am "docs(mqtt): Sparkplug P2 live-verified; MQTT driver complete"` (+ trailer).
---
## Deferred / out of scope
- **Write-through (P3 in the design):** `IWritable` — Sparkplug NCMD/DCMD + plain publish, optimistic-Good + self-correct on echo (mirrors Galaxy fire-and-forget), `WriteIdempotent` default-off for non-idempotent Sparkplug commands, write-topic config. See design §3.7 + §10 (P3 row). `MqttDriver` ships **read/subscribe/discover only** in P1+P2 — its absence of `IWritable` means nodes materialize read-only.
- **`DataSet`/`Template` Sparkplug metrics** — unsupported v1 (skip + warn, or raw-JSON as String). §3.5.
- **`Bytes`/`File` metrics** beyond a base64/raw-String fallback. §3.5.
- **EMQX broker** — Mosquitto is the default fixture; EMQX is the documented alternative when a dashboard/built-in Sparkplug tooling helps (§9), not built here.
## Notes on ordering / parallelism
- **P2 is blocked on the P1 milestone (Task 14).** Every P2 task's `blockedBy` chain roots at Task 14 so plain MQTT ships and is live-verified before Sparkplug work begins — and MQTTnet-5/net10 is proven (Task 0) before any proto/Sparkplug effort.
- Genuine parallel pairs: 5‖6, 8‖10, 11‖12, 16‖17, 18‖19, 23‖24.
- DRY: reuse `EquipmentTagRefResolver<MqttTagDefinition>` (as Modbus/OpcUaClient do), the shared `JsonSerializerOptions`, and one `SparkplugCodec` for both decode (ingest) and encode (rebirth NCMD). YAGNI: no write-through plumbing, no DataSet/Template, no EMQX until a need lands.
@@ -0,0 +1,34 @@
{
"planPath": "docs/plans/2026-07-24-mqtt-sparkplug-driver.md",
"note": "Wave 2 MQTT/Sparkplug B driver. Two phases both in-scope: P1 (plain MQTT, Tasks 0-14) is a complete shippable milestone; P2 (Sparkplug B ingest, Tasks 15-26) builds on it and every P2 task chains back to Task 14. Write-through (NCMD/DCMD/plain publish) is DEFERRED (design P3). Hard rules: Task 0 (MQTTnet-5/net10 restore+build under central pinning with transitive pinning OFF) is the mandatory FIRST gate. Hand-roll Tahu proto over ONE MQTTnet-5 client (NOT SparkplugNet — its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF CentralPackageTransitivePinning). DriverType string is 'Mqtt' everywhere (grep-enforced). Enum-serialization trap: every JSON seam uses shared JsonSerializerOptions with JsonStringEnumConverter. Fixtures enforce TLS+auth, never anonymous/public-broker. Google.Protobuf (3.34.1) + Grpc.Tools (2.76.0) already pinned; MQTTnet is the one new pin. High-risk tasks: 4 (hand-rolled reconnect loop), 18/19 (alias/seq state-machine components), 21 (the full 3.6 ingest matrix).",
"tasks": [
{"id": 0, "subject": "Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build under central pinning", "status": "pending", "classification": "standard", "parallelizableWith": []},
{"id": 1, "subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [0]},
{"id": 2, "subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
{"id": 3, "subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
{"id": 4, "subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [3]},
{"id": 5, "subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)", "status": "pending", "classification": "small", "parallelizableWith": [6], "blockedBy": [3]},
{"id": 6, "subject": "Task 6 (P1): ISubscribable plain topic subscribe→OnDataChange + retained seed", "status": "pending", "classification": "standard", "parallelizableWith": [5], "blockedBy": [2, 4]},
{"id": 7, "subject": "Task 7 (P1): MqttDriver shell — IDriver + authored-only ITagDiscovery (Once) + probe interface", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [5, 6]},
{"id": 8, "subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake", "status": "pending", "classification": "small", "parallelizableWith": [10], "blockedBy": [3]},
{"id": 9, "subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [7, 8]},
{"id": 10, "subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)", "status": "pending", "classification": "standard", "parallelizableWith": [8], "blockedBy": [2]},
{"id": 11, "subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)", "status": "pending", "classification": "trivial", "parallelizableWith": [12], "blockedBy": [10]},
{"id": 12, "subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)", "status": "pending", "classification": "standard", "parallelizableWith": [11], "blockedBy": [2]},
{"id": 13, "subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [9]},
{"id": 14, "subject": "Task 14 (P1): Live /run verify on docker-dev — P1 MILESTONE COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [9, 11, 12, 13]},
{"id": 15, "subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [14]},
{"id": 16, "subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors", "status": "pending", "classification": "standard", "parallelizableWith": [17], "blockedBy": [15]},
{"id": 17, "subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map", "status": "pending", "classification": "small", "parallelizableWith": [16], "blockedBy": [15]},
{"id": 18, "subject": "Task 18 (P2): BirthCache + AliasTable — bind-by-name, rebuild-per-birth", "status": "pending", "classification": "high-risk", "parallelizableWith": [19], "blockedBy": [16, 17]},
{"id": 19, "subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie", "status": "pending", "classification": "high-risk", "parallelizableWith": [18], "blockedBy": [16, 17]},
{"id": 20, "subject": "Task 20 (P2): RebirthRequester NCMD encode", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [16]},
{"id": 21, "subject": "Task 21 (P2): Sparkplug ingest state machine — the 3.6 matrix", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [18, 19, 20]},
{"id": 22, "subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21]},
{"id": 23, "subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync", "status": "pending", "classification": "standard", "parallelizableWith": [24], "blockedBy": [20, 21]},
{"id": 24, "subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator", "status": "pending", "classification": "small", "parallelizableWith": [23], "blockedBy": [12, 17]},
{"id": 25, "subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21, 22, 23]},
{"id": 26, "subject": "Task 26 (P2): Live /run verify Sparkplug — P2 COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [23, 24, 25]}
],
"lastUpdated": "2026-07-24"
}
+771
View File
@@ -0,0 +1,771 @@
# MTConnect Agent Driver Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Ship a read-only, browsable `DriverType = "MTConnect"` Equipment-kind driver (P1 Agent MVP) that surfaces an MTConnect Agent's `/probe` device model as OPC UA nodes and serves live values via `/current` (read) + `/sample` long-poll (subscribe), with browse coming free from the Wave-0 universal discovery browser.
**Architecture:** One `MTConnectDriver` per instance implements `IDriver`, `ITagDiscovery` (`SupportsOnlineDiscovery=true`, `RediscoverPolicy=Once`), `IReadable`, `ISubscribable`, `IHostConnectivityProbe`, `IRediscoverable`**not** `IWritable` (Agent surface is read-only). A thin `IMTConnectAgentClient` seam wraps the HTTP/XML transport (probe/current/sample) so the whole driver is unit-tested against canned XML with no network. A per-instance `MTConnectObservationIndex` holds `dataItemId → latest DataValueSnapshot`, updated by `/current` and the `/sample` pump; `UNAVAILABLE → BadNoCommunication`. Browse is served by the already-shipped `DiscoveryDriverBrowser` — this driver ships **no browser code**, only the `SupportsOnlineDiscovery` opt-in.
**Tech Stack:** `HttpClient` behind the `IMTConnectAgentClient` seam — **TrakHound MTConnect.NET (`-Common` + `-HTTP`, MIT, netstandard2.0) pending the Task 0 verification, else a hand-rolled `System.Xml.Linq` + multipart boundary-reader fallback (drop-in behind the same seam)**; `System.Text.Json` for config DTOs (enum-as-name, `JsonStringEnumConverter` on the probe, `ParseEnum<T>` on the factory); the Wave-0 universal browser seam (`ITagDiscovery.SupportsOnlineDiscovery`).
**Source design:** docs/plans/2026-07-15-mtconnect-driver-design.md
**Program context:** docs/plans/2026-07-15-driver-expansion-program-design.md (§3 shared contract, §4 two-tier browse, §7 fixtures)
**Wave-0 dependency:** docs/plans/2026-07-15-universal-discovery-browser-design.md — the browse picker live-verify (Task 21) is gated on the Wave-0 universal-browser live gate (Gitea #468) being closed.
---
## Cross-cutting constraints (apply to every task)
- **Per-op deadline (R2-01 frozen-peer lesson).** Every agent HTTP call carries a bounded deadline: `/probe` + `/current` wrap in a `CancellationTokenSource(RequestTimeoutMs)` linked to the caller's `ct` AND set `HttpClient.Timeout`; the `/sample` long-poll cannot use `HttpClient.Timeout`, so it runs under a heartbeat watchdog (§7 of the design). No unbounded waits.
- **Enum-serialization trap (project-wide MEMORY).** Enums on any config/tag surface serialize as **name strings**: the AdminUI model writes them via `TagConfigJson.Set` (name), the probe parses with `new JsonStringEnumConverter()`, and the factory keeps enum-carrying DTO fields `string?` + parses via `ParseEnum<T>` (no converter in the factory `JsonOptions`). A numerically-serialized enum faults the driver at deploy.
- **Ctor is connection-free.** The `MTConnectDriver` constructor opens no sockets — every connect happens in `InitializeAsync`. The universal browser's throwaway-instance `CanBrowse` pattern depends on this.
- **Never throw across a capability boundary.** `IReadable` reports per-ref failures as Bad-coded snapshots (throws only if the driver itself is unreachable); `IDriverProbe.ProbeAsync` never throws (returns `Ok=false`).
- **Secrets from env/secret-refs.** No agent URL creds committed or logged.
- **`Float64`/`Int64` are the real `DriverDataType` member names** (verified in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs`).
---
## Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (gates the `.csproj` package refs in Task 1)
**Files:**
- Modify: `docs/plans/2026-07-24-mtconnect-driver.md` (record the decision inline under this task)
The design (§2 "Library verification checklist") flags three research-sourced facts as unverifiable offline. Verify against real NuGet before committing to the dependency:
1. `dotnet package search MTConnect.NET-Common --exact-match` (and `-HTTP`) — confirm the pinned version (`6.9.0.2` or then-current) **exists on nuget.org**.
2. In a throwaway project, `dotnet add package MTConnect.NET-Common -v <pin>` + `dotnet restore` — confirm it **restores** and its TFM set includes `netstandard2.0` (loads on net10).
3. Open the restored package's embedded `LICENSE` — confirm the *pinned version* is **MIT** (older releases carried mixed MIT/Apache/"all rights reserved").
**Decision rule:** all three pass ⇒ TrakHound path (Task 1 adds the two `PackageReference`s). Any fail ⇒ **hand-roll fallback**: `HttpClient` + `System.Xml.Linq` for probe/current + a `multipart/x-mixed-replace` boundary reader for sample (~300500 LoC behind the same `IMTConnectAgentClient` seam; the driver above the seam is byte-identical either way). Record the chosen path and the observed version/TFM/license as a one-paragraph **DECISION** note appended to this task in the plan file.
**No test / no commit** for this task (it's a decision + a doc edit). Commit the plan edit with the next task, or standalone:
```bash
git add docs/plans/2026-07-24-mtconnect-driver.md
git commit -m "docs(mtconnect): record TrakHound-vs-hand-rolled client decision (Task 0)"
```
---
## Task 1: Scaffold the two driver projects + the test project
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (every later task builds on these projects)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the three `<Project Path=...>` lines)
Steps:
- Copy the `.Contracts` csproj boilerplate from `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/…csproj`: `net10.0`, `Nullable=enable`, `ImplicitUsings=enable`, `TreatWarningsAsErrors=true`. `.Contracts` references **only** `Core.Abstractions` (no backend NuGet). Add `GenerateDocumentationFile=true` (matches the fleet).
- The runtime `Driver.MTConnect.csproj` references `Core`, `Core.Abstractions`, `.Contracts`, and — **per the Task 0 decision** — either the two TrakHound packages or nothing extra (hand-roll uses only the BCL). Add `InternalsVisibleTo` the Tests project (mirror Modbus).
- The `.Tests` csproj: xUnit + Shouldly (copy `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/…csproj` package block), ProjectReferences to `Driver.MTConnect` + `.Contracts`. Mark the future `Fixtures/*.xml` as `CopyToOutputDirectory` (`<Content Include="Fixtures\**\*.xml"><CopyToOutputDirectory>PreserveNewest…`).
- Add all three to `ZB.MOM.WW.OtOpcUa.slnx` beside the Modbus entries (lines ~2992 pattern).
Verify:
```bash
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj # expect: PASS (empty compile)
```
```bash
git add -A && git commit -m "build(mtconnect): scaffold Contracts+Driver+Tests projects, add to slnx (Task 1)"
```
---
## Task 2: `MTConnectDriverOptions` + `MTConnectTagDefinition` in `.Contracts`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3, Task 4
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinition.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverOptionsTests.cs`
Mirror `ModbusDriverOptions`: strongly-typed record/POCO holding `AgentUri` (required), optional `DeviceName` scope, `RequestTimeoutMs`, `SampleIntervalMs`, `SampleCount`, `HeartbeatMs`, a `Probe` sub-record (mirror `ModbusProbeOptions`: `Enabled`/`Interval`/`Timeout`), and a `Reconnect` sub-record (`MinBackoffMs`/`MaxBackoffMs`/multiplier — mirror `ModbusReconnectOptions`). `MTConnectTagDefinition`: one record per authored tag — `FullName` (= `dataItemId`), `DriverDataType`, `IsArray`, `ArrayDim`, plus `mtCategory`/`mtType`/`mtSubType`/`units` metadata.
TDD — write the failing test first (defaults + required-field shape):
```csharp
[Fact]
public void Options_default_sample_and_heartbeat_are_sane()
{
var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000" };
o.SampleIntervalMs.ShouldBeGreaterThan(0);
o.HeartbeatMs.ShouldBeGreaterThan(0);
o.Probe.Enabled.ShouldBeTrue();
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDriverOptionsTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): driver options + tag definition records (Task 2)"
```
---
## Task 3: `MTConnectDataTypeInference.Infer` + golden type-map test
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 4
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDataTypeInference.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataTypeInferenceTests.cs`
Pure `static DriverDataType Infer(string category, string? type, string? units, string? representation)` implementing the §3.3 table. It lives in `.Contracts` so the driver, browser-commit, and the typed editor all agree. Return `(DriverDataType, bool isArray, uint? arrayDim)` (or a small record) so `TIME_SERIES` can carry `IsArray=true` + declared `sampleCount`.
Golden table (design §3.3):
| category / shape | result |
|---|---|
| `SAMPLE` numeric with `units` | `Float64` |
| `SAMPLE` `representation=TIME_SERIES` | `Float64` array (`IsArray=true`, `ArrayDim=sampleCount` or null) |
| `EVENT` numeric type (`PartCount`, `Line`) | `Int64` |
| `EVENT` controlled-vocab (`Execution`, `ControllerMode`, `Availability`) | `String` |
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
| `CONDITION` | `String` |
TDD — one `[Theory]` covering every row (golden):
```csharp
[Theory]
[InlineData("SAMPLE", "Position", "MILLIMETER", null, DriverDataType.Float64)]
[InlineData("EVENT", "PartCount", null, null, DriverDataType.Int64)]
[InlineData("EVENT", "Execution", null, null, DriverDataType.String)]
[InlineData("EVENT", "Program", null, null, DriverDataType.String)]
[InlineData("CONDITION", "Temperature", null, null, DriverDataType.String)]
public void Infer_maps_the_v1_table(string cat, string type, string? units, string? rep, DriverDataType expected)
=> MTConnectDataTypeInference.Infer(cat, type, units, rep).DataType.ShouldBe(expected);
[Fact]
public void TimeSeries_sample_is_a_float64_array_with_declared_count()
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", sampleCount: 8);
r.DataType.ShouldBe(DriverDataType.Float64);
r.IsArray.ShouldBeTrue();
r.ArrayDim.ShouldBe(8u);
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDataTypeInferenceTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): pure data-type inference table + golden test (Task 3)"
```
---
## Task 4: Capture the canned XML fixtures
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/probe.xml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current.xml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample.xml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample-gap.xml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current-unavailable.xml`
These canned docs drive the bulk of unit coverage with no network (design §8.1). Capture from a demo agent (`https://demo.mtconnect.org/probe` / `/current` / `/sample`) or hand-author minimal valid MTConnect 1.x XML covering:
- `probe.xml` — one `MTConnectDevices` with a `Device` → nested `Component``DataItem`s spanning every §3.3 category (a `SAMPLE` w/ units, a `TIME_SERIES` w/ `sampleCount`, an `EVENT` numeric, an `EVENT` controlled-vocab, a `CONDITION`), each with a distinct `id`.
- `current.xml` — an `MTConnectStreams` with a `Header instanceId=... nextSequence=...` and one observation per data item, including at least one `<... >UNAVAILABLE</...>`.
- `sample.xml` — an `MTConnectStreams` chunk with several observations and a `Header nextSequence` that continues contiguously from `current.xml`.
- `sample-gap.xml` — a `Header` whose `firstSequence` is **newer** than the `from` the driver would request (the ring-buffer-overflow / forced-`nextSequence`-gap case Task 7 + Task 11 assert re-baseline on).
- `current-unavailable.xml` — every observation `UNAVAILABLE` (Task 8's quality-mapping fixture).
No code, no test yet — verify the files copy to bin:
```bash
dotnet build tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests # PASS; then confirm bin/.../Fixtures/*.xml exist
git add -A && git commit -m "test(mtconnect): canned probe/current/sample XML fixtures incl. forced-gap (Task 4)"
```
---
## Task 5: `IMTConnectAgentClient` seam + return DTOs
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3, Task 4
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs` (parsed model + observation records)
Define the seam the whole driver is tested behind:
```csharp
public interface IMTConnectAgentClient
{
Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct);
Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct);
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
}
```
`MTConnectProbeModel` — devices → components (nested) → data items (`Id`, `Name?`, `Category`, `Type`, `SubType?`, `Units?`, `Representation?`, `SampleCount?`). `MTConnectStreamsResult``long InstanceId`, `long NextSequence`, `long FirstSequence`, and `IReadOnlyList<MTConnectObservation>` (`DataItemId`, `Value` (string or `"UNAVAILABLE"`), `TimestampUtc`). These DTOs are the neutral shape both the TrakHound adapter and the hand-roll fallback produce, so the driver never sees TrakHound types (or `XElement`) directly.
Verify (compiles only):
```bash
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect # PASS
git add -A && git commit -m "feat(mtconnect): IMTConnectAgentClient seam + neutral parse DTOs (Task 5)"
```
---
## Task 6: `MTConnectAgentClient` — parse `/probe` into the device model
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs` (probe leg only this task)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectProbeParseTests.cs`
Implement `ProbeAsync` — either via TrakHound's `MTConnectHttpClient` (map its `IDevice`/`IComponent`/`IDataItem` into the neutral `MTConnectProbeModel`) or the hand-roll `System.Xml.Linq` walk of `probe.xml`. The `/probe` call carries the linked-CTS deadline. To keep the parse itself unit-testable without a socket, factor the byte→model parse into an internal static (`MTConnectProbeParser.Parse(Stream|string)`) and feed it the fixture directly.
TDD:
```csharp
[Fact]
public async Task Probe_parse_yields_nested_components_and_all_dataitems()
{
var xml = await File.ReadAllTextAsync("Fixtures/probe.xml");
var model = MTConnectProbeParser.Parse(xml);
model.Devices.ShouldHaveSingleItem();
var ids = model.Devices[0].AllDataItems().Select(d => d.Id).ToList();
ids.ShouldContain("<the CONDITION dataItem id from the fixture>");
model.Devices[0].Components.ShouldNotBeEmpty(); // nesting preserved
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectProbeParseTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): agent client /probe parse to device model (Task 6)"
```
---
## Task 7: `MTConnectAgentClient` — parse `/current` + `/sample`, detect the sequence gap
**Classification:** high-risk (ring-buffer / sequence paging correctness)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs` (add current/sample legs + `MTConnectStreamsParser`)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs`
`CurrentAsync` parses `current.xml``MTConnectStreamsResult` (Header `instanceId`/`nextSequence`/`firstSequence` + observations). `SampleAsync(from, ct)` yields one `MTConnectStreamsResult` per multipart chunk and **exposes the gap signal**: expose a helper `bool IsSequenceGap(long requestedFrom, MTConnectStreamsResult chunk) => chunk.FirstSequence > requestedFrom` so Task 11's pump can re-baseline. Advance the caller's `from` to `chunk.NextSequence` (contiguous). Chunk framing (the multipart boundary reader) is TrakHound-internal or the hand-roll's boundary reader — either way the parser is fed one chunk's XML.
TDD (the forced-gap fixture is the load-bearing case):
```csharp
[Fact]
public void Current_parse_reads_header_sequences_and_observations()
{
var r = MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/current.xml"));
r.NextSequence.ShouldBeGreaterThan(0);
r.Observations.ShouldNotBeEmpty();
}
[Fact]
public void Sequence_gap_is_detected_when_firstSequence_exceeds_requested_from()
{
var chunk = MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/sample-gap.xml"));
MTConnectAgentClient.IsSequenceGap(requestedFrom: 1, chunk).ShouldBeTrue();
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectStreamsParseTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)"
```
---
## Task 8: `MTConnectObservationIndex` + `UNAVAILABLE → BadNoCommunication`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs`
Thread-safe `dataItemId → DataValueSnapshot` map updated by `/current` and `/sample`. The snapshot builder maps a raw observation → `DataValueSnapshot`:
- value `UNAVAILABLE``new(null, BadNoCommunication, ts, now)` where `private const uint BadNoCommunication = 0x80310000u;` (design §3.3 — declared as a const, the Modbus `StatusBadCommunicationError` pattern; renders by name in the CLI's `SnapshotFormatter`).
- a present value → coerced to the tag's `DriverDataType` with `StatusCode = Good (0)`, `SourceTimestampUtc = observation.timestamp`.
- a `dataItemId` never seen / empty condition → `Bad`-coded snapshot.
TDD (drive off `current.xml` + `current-unavailable.xml`):
```csharp
[Fact]
public void Unavailable_observation_maps_to_BadNoCommunication_with_null_value()
{
var idx = new MTConnectObservationIndex();
idx.Apply(MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/current-unavailable.xml")));
var snap = idx.Get("<a dataItemId from the fixture>");
snap.Value.ShouldBeNull();
snap.StatusCode.ShouldBe(0x80310000u);
}
[Fact]
public void Present_value_indexes_good_with_source_timestamp()
{
var idx = new MTConnectObservationIndex();
idx.Apply(MTConnectStreamsParser.Parse(File.ReadAllText("Fixtures/current.xml")));
var snap = idx.Get("<a good dataItemId>");
snap.StatusCode.ShouldBe(0u);
snap.SourceTimestampUtc.ShouldNotBeNull();
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectObservationIndexTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): observation index + UNAVAILABLE->BadNoCommunication (Task 8)"
```
---
## Task 9: `MTConnectDriver` shell — `IDriver` lifecycle
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs` (IDriver members only this task)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs`
Ctor `MTConnectDriver(MTConnectDriverOptions options, string driverInstanceId, Func<MTConnectDriverOptions, IMTConnectAgentClient>? agentClientFactory = null, ILogger<MTConnectDriver>? logger = null)`**connection-free**. `agentClientFactory` defaults to the real `MTConnectAgentClient`; tests inject a canned-XML fake implementing `IMTConnectAgentClient`. Implement:
- `InitializeAsync`: build the client, run one `/probe` under the deadline (cache `IDevice[]` model + `Header.instanceId`), prime the index with one `/current`; `DriverState.Healthy` on success, rethrow → Faulted on failure.
- `ReinitializeAsync`: stop the sample stream, re-Initialize (config-only unless `AgentUri`/`DeviceName` changed).
- `ShutdownAsync`: stop stream, dispose client.
- `GetHealth`: `DriverHealth(State, LastSuccessfulRead, LastError)``LastSuccessfulRead` = last `/current`|`/sample` chunk time.
- `GetMemoryFootprint` / `FlushOptionalCachesAsync`: footprint ≈ probe model + index; flush drops the probe/browse cache, keeps the index.
TDD with a canned-XML fake client:
```csharp
[Fact]
public async Task Initialize_primes_index_and_reports_healthy()
{
var fake = CannedAgentClient.FromFixtures(); // test helper: serves probe.xml + current.xml
var d = new MTConnectDriver(Opts(), "mt1", _ => fake);
await d.InitializeAsync("{\"agentUri\":\"http://x\"}", default);
d.GetHealth().State.ShouldBe(DriverState.Healthy);
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDriverLifecycleTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): driver shell IDriver lifecycle over agent-client seam (Task 9)"
```
---
## Task 10: `IReadable.ReadAsync` — `/current`, ordered snapshots
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 12, Task 13
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectReadTests.cs`
`ReadAsync(fullReferences, ct)`: one `/current` under the per-call deadline, index the whole-device response, return **one `DataValueSnapshot` per requested ref in order**; a ref absent from the response → a `Bad`-coded snapshot (never a throw). Batch reads cost one round-trip.
TDD:
```csharp
[Fact]
public async Task Read_returns_one_snapshot_per_ref_in_order_absent_ref_is_bad()
{
var d = await InitializedDriver();
var res = await d.ReadAsync(new[] { "<good id>", "does-not-exist" }, default);
res.Count.ShouldBe(2);
res[0].StatusCode.ShouldBe(0u);
(res[1].StatusCode & 0x80000000u).ShouldBe(0x80000000u); // Bad
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectReadTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): IReadable via /current, ordered per-ref snapshots (Task 10)"
```
---
## Task 11: `ISubscribable` — `/sample` long-poll pump + ring-buffer re-baseline
**Classification:** high-risk (streaming state + re-baseline correctness)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectSampleHandle.cs` (`ISubscriptionHandle`)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs`
- `SubscribeAsync`: on the **first** subscription, start the shared sample stream from `nextSequence`; record the subscribed ref set; immediately fire `OnDataChange` for each subscribed ref from the primed `/current` (initial-data convention); return a handle (monotonic id + `DiagnosticId`). The stream-start handshake must complete inside the invoker's Subscribe timeout; the long-lived pump then runs under the heartbeat watchdog, not that timeout.
- Pump: each chunk → for each observation whose `dataItemId ∈` subscribed set, update the index + raise `OnDataChange(handle, dataItemId, snapshot)`; advance `from = nextSequence`.
- **Ring-buffer overflow:** when `IsSequenceGap(from, chunk)` (Task 7) → re-`/current` to re-baseline, then resume from the new `nextSequence`.
- `UnsubscribeAsync`: drop the handle's refs; stop the shared stream when the set empties.
TDD — assert the initial-data callback AND that a forced gap triggers a re-baseline `/current` (drive `SampleAsync` to yield `sample-gap.xml`):
```csharp
[Fact]
public async Task Subscribe_fires_initial_data_from_current()
{
var d = await InitializedDriver();
var seen = new List<string>();
d.OnDataChange += (_, e) => seen.Add(e.FullReference);
await d.SubscribeAsync(new[] { "<good id>" }, TimeSpan.FromMilliseconds(500), default);
seen.ShouldContain("<good id>");
}
[Fact]
public async Task Sequence_gap_triggers_recurrent_current_rebaseline()
{
var fake = CannedAgentClient.WithGapThenResume(); // sample-gap.xml then a contiguous chunk
var d = await InitializedDriver(fake);
await d.SubscribeAsync(new[] { "<good id>" }, TimeSpan.FromMilliseconds(50), default);
await fake.PumpOnce();
fake.CurrentCallCount.ShouldBeGreaterThan(1); // initial prime + re-baseline
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectSubscribeTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): ISubscribable /sample pump + ring-buffer re-baseline (Task 11)"
```
---
## Task 12: `ITagDiscovery.DiscoverAsync` + `SupportsOnlineDiscovery=true`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 13
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs`
`public bool SupportsOnlineDiscovery => true;` + `public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;` (probe is synchronous + complete — this one member override is the whole browse opt-in; the Wave-0 universal browser does the rest). `DiscoverAsync(builder, ct)` streams the cached/`/probe` model into the builder:
- each `Device``builder.Folder(name, name)` → child builder;
- each nested `Component` → recursive `Folder(name, displayName)` on the parent's child builder;
- each `DataItem``child.Variable(browseName, displayName, attr)` with `browseName = dataItem.Name ?? dataItem.Id`, and
`attr = new DriverAttributeInfo(FullName: dataItem.Id, DriverDataType: <Infer(...).DataType>, IsArray: rep==TIME_SERIES, ArrayDim: <declared sampleCount or null>, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: category=="CONDITION")`.
- If `DeviceName` is set, stream only that device's subtree.
**Critical:** `FullName == dataItem.Id` — the value the universal browser commits as `TagConfig.FullName` and the key read/subscribe resolve against, aligned by construction.
TDD with a capturing builder (a test double, or reuse Wave-0's `CapturingAddressSpaceBuilder` if referenceable):
```csharp
[Fact]
public async Task Discover_streams_device_component_dataitem_tree_leaf_fullname_is_dataitemid()
{
var d = await InitializedDriver();
var cap = new CapturingBuilder();
await d.DiscoverAsync(cap, default);
cap.Variables.Select(v => v.Attr.FullName).ShouldContain("<CONDITION dataItem id>");
cap.Variables.Single(v => v.Attr.IsAlarm).Attr.DriverDataType.ShouldBe(DriverDataType.String);
d.SupportsOnlineDiscovery.ShouldBeTrue();
d.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDiscoverTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): ITagDiscovery streams tree + SupportsOnlineDiscovery opt-in (Task 12)"
```
---
## Task 13: `IHostConnectivityProbe` + `IRediscoverable` (instanceId watch)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 12
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs`
- `IHostConnectivityProbe`: one `HostConnectivityStatus` per Agent (`HostName = AgentUri`); a cheap periodic `/probe` (or the sample-stream heartbeat) flips `Running ↔ Stopped`; raise `OnHostStatusChanged` on transition. Enable/interval from `MTConnectDriverOptions.Probe`.
- `IRediscoverable`: cache `Header.instanceId` from Initialize; on every `/current`/`/sample` chunk, a **changed** `instanceId` means the Agent restarted / its model changed → raise `OnRediscoveryNeeded(new("MTConnect agent instanceId changed", null))`. This is why `RediscoverPolicy = Once` is safe — instanceId change, not polling, drives re-discovery.
TDD:
```csharp
[Fact]
public async Task InstanceId_change_raises_rediscovery()
{
var fake = CannedAgentClient.WithInstanceIdChangeOnNextChunk();
var d = await InitializedDriver(fake);
RediscoveryEventArgs? got = null;
((IRediscoverable)d).OnRediscoveryNeeded += (_, e) => got = e;
await d.SubscribeAsync(new[] { "<good id>" }, TimeSpan.FromMilliseconds(50), default);
await fake.PumpOnce();
got.ShouldNotBeNull();
}
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectHostAndRediscoverTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): host-connectivity probe + instanceId rediscover (Task 13)"
```
---
## Task 14: `MTConnectDriverProbe : IDriverProbe`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 15
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs`
Mirror `ModbusDriverProbe`: `DriverType => "MTConnect"`; parse the config DTO with a `JsonSerializerOptions` carrying `new JsonStringEnumConverter()` (copy `ModbusDriverProbe._opts` — the enum-serialization gotcha); one-shot `GET {AgentUri}/probe` under `timeout` (linked-CTS); `Ok=true`+latency on any valid `MTConnectDevices` response; `Ok=false`+message on TCP/HTTP/timeout failure. **Never throws** (`IDriverProbe` contract).
TDD (unit — parse + never-throw; live reachability is Task 20's integration job):
```csharp
[Fact]
public async Task Probe_on_unreachable_agent_returns_not_ok_and_does_not_throw()
{
var probe = new MTConnectDriverProbe();
var r = await probe.ProbeAsync("{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromMilliseconds(300), default);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNull();
}
[Fact]
public async Task Probe_on_blank_config_returns_not_ok()
=> (await new MTConnectDriverProbe().ProbeAsync("{}", TimeSpan.FromSeconds(1), default)).Ok.ShouldBeFalse();
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectDriverProbeTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): IDriverProbe one-shot /probe reachability (Task 14)"
```
---
## Task 15: `MTConnectDriverFactoryExtensions`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 14
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs`
Copy the Modbus factory: `public const string DriverTypeName = "MTConnect";` `Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)``registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory))`. `CreateInstance` deserializes an `MTConnectDriverConfigDto` (nullable-init DTO with `AgentUri`, `DeviceName`, timeouts, `Tags`, `Probe`), validates `AgentUri` present, builds `MTConnectDriverOptions`, returns `new MTConnectDriver(options, id, agentClientFactory: null, logger: loggerFactory?.CreateLogger<MTConnectDriver>())`. Factory `JsonOptions` mirror Modbus (`PropertyNameCaseInsensitive`, `ReadCommentHandling=Skip`, `AllowTrailingCommas`, **no enum converter** — enum-carrying DTO fields stay `string?` + go through `ParseEnum<T>`).
TDD:
```csharp
[Fact]
public void Factory_builds_a_driver_from_minimal_config()
{
var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");
d.DriverType.ShouldBe("MTConnect");
d.DriverInstanceId.ShouldBe("mt1");
}
[Fact]
public void Factory_rejects_config_without_agentUri()
=> Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{}"));
```
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "FullyQualifiedName~MTConnectFactoryTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): driver factory + config DTO (Task 15)"
```
---
## Task 16: Host registration + `DriverTypeNames.MTConnect` + guard-test parity
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs` (add `MTConnect` const + append to `All`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs` (Register call + probe alias + `TryAddEnumerable`)
- Modify: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj` (add ProjectReference to `Driver.MTConnect`)
Three coupled edits that **must land together** to keep `DriverTypeNamesGuardTests` green (it asserts exact-set parity between `DriverTypeNames.All` and the reflection-discovered registered factories, scanning `ZB.MOM.WW.OtOpcUa.Driver.*.dll` in the test's bin):
1. Add `public const string MTConnect = "MTConnect";` to `DriverTypeNames` and append it to `All` (design uses `DriverTypeNames.MTConnect` in the editor map/validator per the CLAUDE.md "keyed off constants" rule).
2. In `DriverFactoryBootstrap`: add `using MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe;`, `Driver.MTConnect.MTConnectDriverFactoryExtensions.Register(registry, loggerFactory);` in `Register(...)`, and `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MTConnectProbe>());` in `AddOtOpcUaDriverProbes` (so the probe reaches admin-only nodes — the admin-pinned Test-Connect singleton; `TryAddEnumerable` prevents the fused-node double-register that would make `ToDictionary(p=>p.DriverType)` throw).
3. Add the `Driver.MTConnect` ProjectReference to the guard-test project — **without it the new assembly isn't in bin, the guard doesn't discover the factory, and the new constant fails `Every_constant_matches_a_registered_factory`.**
Verify (the guard test is the gate here):
```bash
dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter "FullyQualifiedName~DriverTypeNamesGuardTests" # RED before the ProjectReference/Register land together, GREEN after
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.Host # PASS
git add -A && git commit -m "feat(mtconnect): host registration + DriverTypeNames const + guard parity (Task 16)"
```
---
## Task 17: AdminUI typed model `MTConnectTagConfigModel`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs`
- Test: `tests/Server/<AdminUI test project>/MTConnectTagConfigModelTests.cs` (mirror the Modbus model test's location)
Copy `ModbusTagConfigModel`: pure `FromJson`/`ToJson`/`Validate`, **preserves unknown keys** via the `TagConfigJson` bag. Fields: `FullName` (dataItemId, required), `MtCategory`/`MtType`/`MtSubType` (from probe — read-only in UI), `DataType` (`DriverDataType` override), `Units`, `MtDevice`/`MtComponent` (author context). `ToJson` writes enums via `TagConfigJson.Set` (**name strings** — the enum-serialization trap). `Validate()` returns an error when `FullName` is blank.
TDD:
```csharp
[Fact]
public void Roundtrip_preserves_unknown_keys_and_writes_enum_as_name()
{
var json = "{\"fullName\":\"dev1_pos\",\"dataType\":\"Float64\",\"somethingUnknown\":42}";
var m = MTConnectTagConfigModel.FromJson(json);
var outJson = m.ToJson();
outJson.ShouldContain("\"somethingUnknown\":42");
outJson.ShouldContain("\"dataType\":\"Float64\""); // name, never a number
}
[Fact]
public void Validate_blocks_blank_fullname()
=> MTConnectTagConfigModel.FromJson("{}").Validate().ShouldNotBeNull();
```
```bash
dotnet test tests/Server/<AdminUI test project> --filter "FullyQualifiedName~MTConnectTagConfigModelTests" # RED, then GREEN
git add -A && git commit -m "feat(mtconnect): typed AdminUI tag-config model (Task 17)"
```
---
## Task 18: AdminUI editor razor + map/validator registration
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs`
Thin razor shell over `MTConnectTagConfigModel` (mirror `ModbusTagConfigEditor.razor`): `FullName` text, read-only `mtCategory`/`mtType`, a `DataType` override `<select>`. Because most tags arrive via the browse picker (which fills the model), the editor is mostly a confirm/override surface. Register:
- `[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor)` in `TagConfigEditorMap`.
- `[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate()` in `TagConfigValidator`.
(No AdminUI `IDriverBrowser` DI line — browse is the universal browser, already registered.)
Verify (AdminUI has no bUnit — Razor binding is validated live in Task 21; here just compile):
```bash
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI # PASS
git add -A && git commit -m "feat(mtconnect): typed tag editor razor + map/validator entries (Task 18)"
```
---
## Task 19: `mtconnect/cppagent` docker fixture
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 17, Task 18
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg` (if the image needs it)
Compose one `mtconnect/cppagent` service, **`project: lmxopcua` label on the service** (host-side `lmxopcua-fix` convention), seeded with a canned `Devices.xml` (+ the image's built-in adapter/simulator so `/current` returns live-ish data), exposed on the shared docker host `10.100.0.35`. Deploy via `lmxopcua-fix sync mtconnect` + `lmxopcua-fix up mtconnect` (repo `Docker/` is source of truth; `/opt/otopcua-mtconnect/` is the mirror). Model the compose on the Modbus fixture's shape.
No .NET test in this task (fixture only). Verify the compose parses:
```bash
docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml config # PASS
git add -A && git commit -m "test(mtconnect): cppagent docker fixture + seeded Devices.xml (Task 19)"
```
---
## Task 20: Env-gated integration suite against cppagent
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
A `*.IntegrationTests` suite that reads the agent endpoint from an env var (e.g. `MTCONNECT_AGENT_ENDPOINT`, default `http://10.100.0.35:5000`) and **skips cleanly when unset/down** (the Modbus/S7 pattern — `Skip.If(...)` or a fixture-reachability guard). Cover the real HTTP round-trips a canned fixture can't: `MTConnectDriverProbe` reachability green, `InitializeAsync` + `DiscoverAsync` build a non-empty tree, `ReadAsync` returns live values, `SubscribeAsync` delivers at least one `OnDataChange` from the live `/sample` stream.
Verify it skips offline (safe on macOS with no fixture up):
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests # all tests SKIP when the env var/endpoint is absent
git add -A && git commit -m "test(mtconnect): env-gated cppagent integration suite (Task 20)"
```
---
## Task 21: Live `/run` verify on docker-dev — browse picker, editor, read, subscribe, deploy
**Classification:** high-risk (live; Razor + deploy-inertness bugs pass unit tests)
**Estimated implement time:** ~5 min (driving; excludes fixture spin-up)
**Parallelizable with:** none
**Files:**
- Modify: `docs/plans/2026-07-24-mtconnect-driver.md` (append a LIVE-GATE RESULT note)
> **DEPENDENCY:** this task's browse-picker leg is gated on the **Wave-0 universal-browser live gate (Gitea #468)** being closed — the picker's Browse button, `DiscoveryDriverBrowser.CanBrowse`, and `CapturedTreeBrowseSession` are Wave-0 machinery this driver only opts into via `SupportsOnlineDiscovery=true`. If #468 is still open, run the read/subscribe/deploy legs and record the browse leg as blocked-on-#468.
Bring the cppagent fixture up (`lmxopcua-fix up mtconnect`), author an MTConnect driver on docker-dev (`http://localhost:9200`, auto-authenticated admin — no login), and verify **in the running AdminUI + against `opc.tcp://localhost:4840`** (per the live-verify discipline — Razor binding bugs pass unit tests):
1. **Test Connect** on the MTConnect driver goes green (probe reaches the fixture).
2. **Browse picker** (`/uns` TagModal → Browse): the Device→Component→DataItem tree renders; a picked leaf commits `TagConfig.FullName = <dataItemId>` (Wave-0 dependency).
3. **Typed editor** shows `FullName`/read-only `mtCategory`/`DataType` override and round-trips.
4. **Deploy** the config; via Client.CLI read a picked node (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=…;s=…"`) — value present, `UNAVAILABLE` items render `BadNoCommunication`.
5. **Subscribe** (`... subscribe ...`) — live updates flow from the `/sample` stream.
```bash
git add docs/plans/2026-07-24-mtconnect-driver.md
git commit -m "test(mtconnect): live /run gate on docker-dev — browse/read/subscribe/deploy (Task 21)"
```
---
## Task 22: Docs + deferred-writeback note; update the tracking doc
**Classification:** trivial
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `docs/drivers/MTConnect.md` (or a short section — mirror an existing driver doc)
- Modify: `docs/plans/2026-07-24-driver-expansion-tracking.md` (mark MTConnect P1 done + link this plan)
Document the P1 Agent MVP: config keys, browse-via-universal, the `UNAVAILABLE→BadNoCommunication` semantics, CONDITION-as-String, and the fixture recipe. Record **write-back (MTConnect Interfaces) as deferred** — point at `docs/plans/2026-07-15-mtconnect-driver-design.md` §3.6 + §9 (P1.5 native-alarm CONDITION, P2 SHDR ingest are also deferred there). Update the driver-expansion tracking doc's Wave-2 row.
```bash
git add -A && git commit -m "docs(mtconnect): P1 driver guide + tracking-doc update, defer write-back (Task 22)"
```
---
## Deferred (out of P1 — pointers, not scope)
- **Write-back (MTConnect Interfaces)** — design §3.6: read-only Agent surface; revisit only on a concrete deployment need.
- **CONDITION → native Part-9 alarms** (`IAlarmSource`), **`TIME_SERIES` SAMPLE arrays** materialized as OPC UA arrays, **EVENT controlled-vocab → OPC UA enumerations** — design §9 P1.5 fast-follow.
- **SHDR adapter ingest** (`SourceMode: "Agent" | "Shdr"`, loses auto-discovery) — design §9 P2.
@@ -0,0 +1,29 @@
{
"planPath": "docs/plans/2026-07-24-mtconnect-driver.md",
"tasks": [
{"id": 0, "subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)", "status": "pending"},
{"id": 1, "subject": "Task 1: Scaffold the two driver projects + the test project", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test", "status": "pending", "blockedBy": [1]},
{"id": 4, "subject": "Task 4: Capture the canned XML fixtures", "status": "pending", "blockedBy": [1]},
{"id": 5, "subject": "Task 5: IMTConnectAgentClient seam + return DTOs", "status": "pending", "blockedBy": [1]},
{"id": 6, "subject": "Task 6: MTConnectAgentClient — parse /probe into the device model", "status": "pending", "blockedBy": [4, 5]},
{"id": 7, "subject": "Task 7: MTConnectAgentClient — parse /current + /sample, detect the sequence gap", "status": "pending", "blockedBy": [4, 5, 6]},
{"id": 8, "subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication", "status": "pending", "blockedBy": [7]},
{"id": 9, "subject": "Task 9: MTConnectDriver shell — IDriver lifecycle", "status": "pending", "blockedBy": [2, 8]},
{"id": 10, "subject": "Task 10: IReadable.ReadAsync — /current, ordered snapshots", "status": "pending", "blockedBy": [9]},
{"id": 11, "subject": "Task 11: ISubscribable — /sample long-poll pump + ring-buffer re-baseline", "status": "pending", "blockedBy": [9, 7]},
{"id": 12, "subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [9, 6]},
{"id": 13, "subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)", "status": "pending", "blockedBy": [9]},
{"id": 14, "subject": "Task 14: MTConnectDriverProbe : IDriverProbe", "status": "pending", "blockedBy": [2, 5]},
{"id": 15, "subject": "Task 15: MTConnectDriverFactoryExtensions", "status": "pending", "blockedBy": [2, 9]},
{"id": 16, "subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity", "status": "pending", "blockedBy": [14, 15]},
{"id": 17, "subject": "Task 17: AdminUI typed model MTConnectTagConfigModel", "status": "pending", "blockedBy": [3, 16]},
{"id": 18, "subject": "Task 18: AdminUI editor razor + map/validator registration", "status": "pending", "blockedBy": [17]},
{"id": 19, "subject": "Task 19: mtconnect/cppagent docker fixture", "status": "pending", "blockedBy": [16]},
{"id": 20, "subject": "Task 20: Env-gated integration suite against cppagent", "status": "pending", "blockedBy": [19]},
{"id": 21, "subject": "Task 21: Live /run verify on docker-dev — browse picker, editor, read, subscribe, deploy", "status": "pending", "blockedBy": [18, 20]},
{"id": 22, "subject": "Task 22: Docs + deferred-writeback note; update the tracking doc", "status": "pending", "blockedBy": [21]}
],
"lastUpdated": "2026-07-24"
}
+670
View File
@@ -0,0 +1,670 @@
# SQL Poll Driver Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Ship a read-only `Sql` Equipment-kind driver that polls SQL Server tables/views on an interval and publishes selected columns/rows as OPC UA variable nodes, authored through the standard equipment Tags flow with a bespoke schema browser.
**Architecture:** Three new projects mirroring the Modbus split — `Driver.Sql.Contracts` (options/tag DTOs + parser, zero transport deps), `Driver.Sql` (the `IDriver`/`ITagDiscovery`/`IReadable`/`ISubscribable`/`IHostConnectivityProbe` runtime + `ISqlDialect` seam + `SqlServerDialect`, owning `Microsoft.Data.SqlClient`), and `Driver.Sql.Browser` (schema-walk `IBrowseSession`). The `PollGroupEngine`, `EquipmentTagRefResolver<TDef>`, health state machine, and factory shape are all reused from Core; the driver batches tags by query-group (one round-trip per group per poll) and slices result sets back to per-tag snapshots. Every value binds as a `DbParameter`; identifiers are dialect-quoted from catalog-validated names only.
**Tech Stack:** `Microsoft.Data.SqlClient` (6.1.1, already in `Directory.Packages.props`) via `System.Data.Common` base types behind a `DbProviderFactory`; `ISqlDialect` + `SqlProvider` enum seam (only `SqlServer` implemented in v1); `Microsoft.Data.Sqlite` (10.0.7, test-only) for the offline unit fixture; xunit.v3 + Shouldly. **Zero new NuGet dependencies.**
**Source design:** docs/plans/2026-07-15-sql-poll-driver-design.md
**Program design (shared contract):** docs/plans/2026-07-15-driver-expansion-program-design.md §3, §7
**Conventions for every task below:** all projects `net10.0`, `Nullable`/`ImplicitUsings` enabled, `TreatWarningsAsErrors` on product projects. Every commit message ends with the repo trailer `Claude-Session: <session-url>` (omitted from the `git commit` examples for brevity). Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` green before each commit. New test projects use `xunit.v3` + `Shouldly` (see `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/*.csproj`).
---
## Task 0: Scaffold `Driver.Sql.Contracts` project + register in slnx
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (root of the tree)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the project under `/src/Drivers/`)
**Steps:**
1. Create the csproj — refs `Core.Abstractions` ONLY (no transport deps, so AdminUI + browser can reference it without dragging `Microsoft.Data.SqlClient`). `InternalsVisibleTo` the Contracts is not needed. `RootNamespace = ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts`.
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
</ItemGroup>
</Project>
```
2. Add to `ZB.MOM.WW.OtOpcUa.slnx` under the `/src/Drivers/` folder, beside the `Driver.Modbus.Contracts` line.
3. Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect PASS (empty project compiles).
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql.Contracts project"`
---
## Task 1: `SqlProvider` + `SqlTagModel` enums + `SqlDriverConfigDto` (string-enum round-trip)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocks most downstream)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/*` is NOT created here — this task's test lives temporarily in the Contracts consumer; create the Tests project in Task 6. **Instead**, put this task's test in a throwaway `Contracts`-adjacent xunit project? No — to avoid a project just for one test, fold this task's serialization assertion into Task 9's factory enum-serialization test. **This task ships DTOs only; its guard is Task 9.**
> **Note:** Tasks 12 produce pure records/enums with no I/O. Their behavioural guard (string-enum round-trip, strict parse) is the Task 9 factory test + Task 2's parser test (Task 2 creates its own micro-test via the Tests project created in Task 6). To keep TDD honest without a premature test project, **reorder locally**: implement Task 1 DTOs, then Task 6 (Tests project) can be pulled forward if the implementer prefers. The `blockedBy` graph permits this. The concrete failing-test-first discipline begins at Task 2.
**Steps:**
1. `SqlProvider.cs`:
```csharp
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
public enum SqlProvider { SqlServer, Postgres, MySql, Odbc, Oracle }
/// <summary>Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).</summary>
public enum SqlTagModel { KeyValue, WideRow, Query }
```
2. `SqlDriverConfigDto.cs` — the driver-config blob shape (§5.1). Enum-typed fields (`Provider`) so the string converter round-trips names; timeouts as `TimeSpan?`; `ConnectionStringRef` (NOT the connection string). Fields: `Provider`, `ConnectionStringRef`, `DefaultPollInterval`, `OperationTimeout`, `CommandTimeout`, `MaxConcurrentGroups`, `NullIsBad`, `AllowWrites`, `Probe` (`SqlProbeDto{Enabled, Interval}`). `[JsonStringEnumConverter]` is applied at the factory (Task 9), not on the DTO.
3. Build — expect PASS.
4. Commit: `git commit -m "feat(sql): SqlProvider/SqlTagModel enums + SqlDriverConfigDto"`
---
## Task 2: `SqlTagDefinition` + `SqlEquipmentTagParser.TryParse` (strict enum reads, malicious-input safe)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs` (Tests project scaffolded in Task 6 — **pull the csproj creation forward from Task 6 for this task**, or defer this task's test until Task 6 and mark Task 2 blockedBy nothing but verified-at-6. Recommended: create the Tests project now as part of this task's setup.)
**TDD:**
1. **Failing test** — parse a KeyValue blob and a malicious `keyValue`; assert the value is captured verbatim (parser does NOT sanitize — binding is the reader's job) and a typo'd `model` enum rejects:
```csharp
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
public class SqlEquipmentTagParserTests
{
[Fact]
public void TryParse_KeyValue_readsFields_andKeepsMaliciousValueVerbatim()
{
var json = """
{"driver":"Sql","model":"KeyValue","table":"dbo.TagValues","keyColumn":"tag_name",
"keyValue":"'; DROP TABLE x --","valueColumn":"num_value","timestampColumn":"sample_ts","type":"Float64"}
""";
SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue();
def.Model.ShouldBe(SqlTagModel.KeyValue);
def.Table.ShouldBe("dbo.TagValues");
def.KeyValue.ShouldBe("'; DROP TABLE x --"); // captured as-is; the reader BINDS it, never concatenates
def.DeclaredType.ShouldBe(DriverDataType.Float64);
def.Name.ShouldBe("Plant/Sql/dev1/Speed"); // Name == RawPath (forward-router key)
}
[Fact]
public void TryParse_invalidModelEnum_rejectsTag()
=> SqlEquipmentTagParser.TryParse(
"""{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
"raw", out _).ShouldBeFalse();
}
```
2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests` — expect FAIL (types don't exist).
3. **Minimal impl**`SqlTagDefinition` record (`Name` = RawPath, `Model`, `Table`, `KeyColumn`, `KeyValue`, `ValueColumn`, `TimestampColumn`, `ColumnName`, `RowSelectorColumn`, `RowSelectorValue`, `RowSelectorTopByTimestamp`, `DeclaredType` = `DriverDataType?`). `SqlEquipmentTagParser.TryParse(string reference, string rawPath, out SqlTagDefinition def)` — mirror `ModbusTagDefinitionFactory.FromTagConfig`: recognize by leading `{`, read the `model` discriminator via `TagConfigJson.TryReadEnumStrict` (a present-but-invalid enum → `false` → the driver surfaces `BadNodeIdUnknown`, per R2-11), read model-specific fields, read the optional `type` override strictly, set `Name = rawPath`. Wrap in try/catch(JsonException/FormatException) → `false`. **No SQL is built here** — the parser only captures strings.
4. Run test — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlTagDefinition + strict-enum equipment-tag parser"`
---
## Task 3: Scaffold `Driver.Sql` runtime project + register in slnx
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 2
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
**Steps:**
1. Create the csproj per design §2.1 — refs `Core.Abstractions` + `Core` (for `DriverFactoryRegistry` in `ZB.MOM.WW.OtOpcUa.Core.Hosting`) + `Driver.Sql.Contracts`; packages `Microsoft.Data.SqlClient` + `Microsoft.Extensions.Logging.Abstractions`; `InternalsVisibleTo` the `.Tests`.
2. Add to `ZB.MOM.WW.OtOpcUa.slnx` under `/src/Drivers/`.
3. Build — expect PASS.
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql runtime project"`
---
## Task 4: `ISqlDialect` seam + `SqlServerDialect` (QuoteIdentifier, catalog SQL, MapColumnType) — golden queries
**Classification:** high-risk (identifier quoting is the injection boundary)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs`
**TDD:**
1. **Failing test** — golden catalog SQL, `QuoteIdentifier` escape + reject, `MapColumnType` table:
```csharp
[Fact]
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
{
var d = new SqlServerDialect();
d.QuoteIdentifier("Speed").ShouldBe("[Speed]");
d.QuoteIdentifier("a]b").ShouldBe("[a]]b]"); // ] doubled per T-SQL rules
}
[Fact]
public void QuoteIdentifier_rejectsControlCharsAndNul()
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier("x\0y"));
[Theory]
[InlineData("bit", DriverDataType.Boolean)]
[InlineData("int", DriverDataType.Int32)]
[InlineData("bigint", DriverDataType.Int64)]
[InlineData("real", DriverDataType.Float32)]
[InlineData("float", DriverDataType.Float64)]
[InlineData("decimal", DriverDataType.Float64)]
[InlineData("nvarchar", DriverDataType.String)]
[InlineData("datetime2", DriverDataType.DateTime)]
[InlineData("uniqueidentifier", DriverDataType.String)]
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
=> new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);
[Fact]
public void CatalogSql_isInformationSchemaAndParameterized()
{
var d = new SqlServerDialect();
d.LivenessSql.ShouldBe("SELECT 1");
d.ListTablesSql.ShouldContain("INFORMATION_SCHEMA.TABLES");
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
d.ListColumnsSql.ShouldContain("@table");
}
```
2. Run — expect FAIL.
3. **Minimal impl**`ISqlDialect` per design §2.2 (`Provider`, `Factory` = `DbProviderFactory`, `QuoteIdentifier`, `LivenessSql`, `ListSchemasSql`, `ListTablesSql`, `ListColumnsSql`, `MapColumnType`). `SqlServerDialect`: `Factory => SqlClientFactory.Instance`; `QuoteIdentifier` doubles `]`, rejects embedded NUL/control chars via `ArgumentException`; catalog SQL uses `INFORMATION_SCHEMA` with `@schema`/`@table` markers; `MapColumnType` folds SQL type families to `DriverDataType` per design §3.7 (`decimal`/`numeric`/`money``Float64` with the documented precision caveat as an XML remark).
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): ISqlDialect + SqlServerDialect with golden catalog SQL"`
---
## Task 5: `SqlQueryPlan` grouping + parameter binding (pure, no DB)
**Classification:** high-risk (the query-mapping core; GroupKey correctness governs correctness of every read)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs`
**TDD:**
1. **Failing test** — KeyValue tags on the same `(table,keyColumn,valueColumn,timestampColumn)` fold into ONE plan whose SQL is `... WHERE [tag_name] IN (@k0,@k1)` with two bound params; WideRow tags on the same `(table,rowSelector)` fold into one `SELECT <cols> ... WHERE [station_id]=@w`; different tables → different plans:
```csharp
[Fact]
public void KeyValueTags_sameSource_foldIntoOnePlan_withBoundInList()
{
var dialect = new SqlServerDialect();
var tags = new[] {
KvTag("A","dbo.TagValues","tag_name","Line1.Speed","num_value","sample_ts"),
KvTag("B","dbo.TagValues","tag_name","Line1.Temp","num_value","sample_ts"),
};
var plans = SqlGroupPlanner.Plan(tags, dialect).ToList();
plans.Count.ShouldBe(1);
plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
plans[0].SqlText.ShouldContain("[tag_name]");
plans[0].Members.Count.ShouldBe(2);
plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" }); // values are params, not text
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlQueryPlan { object GroupKey, string SqlText, IReadOnlyList<object?> Parameters, IReadOnlyList<SqlTagDefinition> Members, Func<DbDataReader, SqlTagDefinition, ...> }` (indexer supplied by the reader in Task 7; here just the shape + SQL/param builder). `SqlGroupPlanner.Plan(tags, dialect)`: KeyValue → GroupKey `(table,keyColumn,valueColumn,timestampColumn)`, emit `SELECT <keyColumn>,<valueColumn>[,<timestampColumn>] FROM <table> WHERE <keyColumn> IN (@k0..@kN)` with distinct-key params; WideRow → GroupKey `(table, rowSelector)`, emit `SELECT <distinct member columns>[,<selector cols>] FROM <table> WHERE <whereColumn>=@w` (or `ORDER BY <col> DESC` + dialect TOP-1 for `topByTimestamp`). Identifiers via `dialect.QuoteIdentifier`; `<table>` split on `.` and each part quoted. **Every value is a param; zero interpolation.**
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlGroupPlanner folds tags into one parameterized query per group"`
---
## Task 6: Scaffold `Driver.Sql.Tests` + `SqliteDialect` (test) + `SqlitePollFixture`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 12
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
> If Task 2 already created the csproj, this task adds the `SqliteDialect`/fixture and the slnx entry only.
**Steps:**
1. csproj — `xunit.v3` + `Shouldly` + `Microsoft.NET.Test.Sdk` + `xunit.runner.visualstudio`; `PackageReference Microsoft.Data.Sqlite` (10.0.7, test-only — no product dep on SQLite); project-refs `Driver.Sql` + `Driver.Sql.Contracts`. Add to slnx under `/tests/Drivers/`.
2. `SqliteDialect : ISqlDialect``Provider = SqlServer` shim is wrong; add a test-only `Provider` value is not needed — implement `ISqlDialect` directly with `Factory => SqliteFactory.Instance`, `QuoteIdentifier` doubling `"`, `LivenessSql = "SELECT 1"`, catalog SQL over `sqlite_schema` + `PRAGMA table_info(...)`, `MapColumnType` mapping declared affinity (design §9 caveat: SQLite is dynamically typed — keep seed columns explicitly typed). This dialect is also linked by the Browser.Tests (Task 13) via `<Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" />`.
3. `SqlitePollFixture` — opens an in-memory (or temp-file) SQLite connection, seeds a KeyValue table `TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT)` and a wide-row `LatestStatus(station_id INT, oven_temp REAL, sample_ts TEXT)` with a few rows. Exposes the open `SqliteConnection` + a `DbProviderFactory` shim so the driver-under-test can be injected with an already-open connection provider (see Task 8's ctor seam).
4. Build + run (no tests yet) — expect PASS/empty.
5. Commit: `git commit -m "test(sql): Sql.Tests project + SqliteDialect + poll fixture"`
---
## Task 7: `SqlPollReader` core — one query per group, bounded deadline, slice back in input order
**Classification:** high-risk (connection/timeout/result-slicing core)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs`
**TDD:**
1. **Failing test** (against `SqlitePollFixture`) — read two KeyValue refs + one absent key; assert N-in/N-out order, correct values/types, absent key → `BadNoData`, NULL cell → `Uncertain` (default `nullIsBad=false`), source timestamp from `timestampColumn`:
```csharp
[Fact]
public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData()
{
await using var fx = await SqlitePollFixture.CreateAsync(); // seeds Line1.Speed=42.0, Line1.Temp=NULL
var reader = new SqlPollReader(fx.Factory, fx.ConnectionString, new SqliteDialect(),
commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
maxConcurrentGroups: 4, nullIsBad: false, resolve: fx.Resolve);
var refs = new[] { "Speed", "Missing", "Temp" };
var snaps = await reader.ReadAsync(refs, CancellationToken.None);
snaps.Count.ShouldBe(3);
snaps[0].Value.ShouldBe(42.0);
snaps[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // key deleted/absent
snaps[2].Value.ShouldBeNull();
StatusCode.IsUncertain(snaps[2].StatusCode).ShouldBeTrue(); // NULL cell, nullIsBad=false
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlPollReader.ReadAsync(refs, ct)`: resolve each ref → `SqlTagDefinition` (miss → `BadNodeIdUnknown`); `SqlGroupPlanner.Plan(...)`; per group under a `SemaphoreSlim(maxConcurrentGroups)`: `await using var conn = factory.CreateConnection(); conn.ConnectionString = connStr; await conn.OpenAsync(linkedCt)`; build `DbCommand` with `CommandTimeout = (int)commandTimeout.TotalSeconds` and bound params; `using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(operationTimeout); await using var rdr = await cmd.ExecuteReaderAsync(linked.Token)`; index rows by key/selector; map each member's cell → `DataValueSnapshot` (type per `DeclaredType` ?? `dialect.MapColumnType`-inferred; NULL → `nullIsBad ? Bad : Uncertain`; absent key → `BadNoData`; source timestamp from `timestampColumn` else read wall-clock). **Reassemble in input order** (`DataValueSnapshot[refs.Count]`). Per-tag failure = Bad-coded snapshot, NOT an exception; the whole call throws only if the connection itself is unreachable (→ engine backoff). `await using` both connection and reader — never leak. Add `SqlStatusCodes` static (BadNoData/BadTimeout/BadNodeIdUnknown/BadCommunicationError/Uncertain constants) if not reusing an existing helper.
4. Run — expect PASS. Add a NULL-cell test with `nullIsBad:true``Bad`.
5. Commit: `git commit -m "feat(sql): SqlPollReader — grouped read, bounded deadline, ordered slice-back"`
---
## Task 8: `SqlDriver` shell — IDriver / ITagDiscovery / IReadable / ISubscribable / IHostConnectivityProbe
**Classification:** high-risk (lifecycle + poll-engine wiring + connection validation)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs`
**TDD:**
1. **Failing test** — construct `SqlDriver` with an injected dialect + factory + already-resolved connection string (SQLite fixture) + authored `RawTagEntry` list; `InitializeAsync``Healthy`; `DiscoverAsync` streams one Variable per authored tag with `SecurityClass=ViewOnly` + `RediscoverPolicy=Once` + `SupportsOnlineDiscovery=false`; `ReadAsync` delegates to the reader; a subscribe fires an initial change:
```csharp
[Fact]
public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly()
{
await using var fx = await SqlitePollFixture.CreateAsync();
var driver = SqlDriver.ForTest(fx, rawTags: fx.AuthoredRawTags);
await driver.InitializeAsync(fx.ConfigJson, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
var cap = new CapturingBuilder();
await ((ITagDiscovery)driver).DiscoverAsync(cap, CancellationToken.None);
cap.Variables.ShouldContain(v => v.FullName == "Speed" && v.SecurityClass == SecurityClassification.ViewOnly);
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlDriver : IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable`. Mirror `ModbusDriver`: fields grouped up top; `_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(...)`; `_poll = new PollGroupEngine(reader: ReadAsync, onChange: ..., onError: HandlePollError, backoffCap: 30s)`. `InitializeAsync`: build `_tagsByRawPath` from `_options.RawTags` via `SqlEquipmentTagParser.TryParse` (miss = logged skip, never throw); open ONE connection, run `dialect.LivenessSql` under `CommandTimeout` + linked CTS, dispose; success → `Healthy`, failure → `Faulted` + `LastError`. `ReadAsync``_reader.ReadAsync`. `SubscribeAsync``_poll.Subscribe`; `UnsubscribeAsync``_poll.Unsubscribe`. `IHostConnectivityProbe`: single logical host (server host from the connection string) with `Running/Stopped` from the last poll/liveness outcome + `OnHostStatusChanged` on transitions. `GetMemoryFootprint => 0`; `FlushOptionalCachesAsync => Task.CompletedTask`; `ReinitializeAsync` = teardown+init. `DriverType => DriverTypeNames.Sql`. Add a `ForTest(...)` internal factory that injects dialect+factory+connString (production path resolves those in the factory, Task 9). **Pooling: open-use-dispose per poll (already in the reader); no long-lived connection.**
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlDriver shell wiring PollGroupEngine + probe + read-only discovery"`
---
## Task 9: `SqlDriverFactoryExtensions` + `connectionStringRef` env resolution + enum-serialization guard
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs`
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs`
**TDD:**
1. **Failing test**`CreateInstance` with a config carrying `"provider":"SqlServer"` builds a driver; a missing `connectionStringRef` throws a clean error; the connection-string ref resolves from env `Sql__ConnectionStrings__<ref>`; **enum-serialization guard** — a config author serializing `provider` as a NUMBER still parses (`JsonStringEnumConverter` accepts ordinals) AND the round-trip writes the NAME:
```csharp
[Fact]
public void CreateInstance_missingConnectionStringRef_throwsActionable()
=> Should.Throw<InvalidOperationException>(() =>
SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null))
.Message.ShouldContain("connectionStringRef");
[Fact]
public void ConnectionStringRef_resolvesFromEnv()
{
Environment.SetEnvironmentVariable("Sql__ConnectionStrings__MesStaging", "Server=x;Database=y;");
SqlConnectionStringResolver.Resolve("MesStaging").ShouldBe("Server=x;Database=y;");
}
[Fact]
public void Provider_serializesAsNameNotNumber()
{
var json = JsonSerializer.Serialize(new SqlDriverConfigDto { Provider = SqlProvider.SqlServer },
SqlDriverFactoryExtensions.JsonOptionsForTest);
json.ShouldContain("\"SqlServer\"");
json.ShouldNotContain("\"provider\":0");
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlConnectionStringResolver.Resolve(string @ref)``Environment.GetEnvironmentVariable($"Sql__ConnectionStrings__{@ref}")` (direct env read, deliberate — the factory closure has no `IConfiguration`, per design §8.2), throws actionable if absent. `SqlDriverFactoryExtensions`: `const DriverTypeName = DriverTypeNames.Sql`; `Register(registry, loggerFactory)``registry.Register(DriverTypeName, (id,json)=>CreateInstance(id,json,loggerFactory))`; `CreateInstance` deserializes `SqlDriverConfigDto` with `JsonOptions` carrying `JsonStringEnumConverter` + `UnmappedMemberHandling.Skip`, validates `ConnectionStringRef` present, builds `SqlServerDialect` from `Provider` (P1: only `SqlServer` constructible; others throw "provider not available in this build"), resolves the connection string, constructs the `SqlPollReader` + `SqlDriver`. **Never log the resolved connection string** (log provider + server host + database only). Expose `JsonOptionsForTest` internal.
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): factory + connectionStringRef env resolution + string-enum guard"`
---
## Task 10: `SqlDriverProbe` (SELECT 1 liveness, bounded timeout)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5, Task 9, Task 12
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs`
**TDD:**
1. **Failing test** — against the SQLite fixture (inject dialect+factory), `ProbeAsync` returns green with latency; a bad connection string returns a red result with the error message (never throws):
```csharp
[Fact]
public async Task Probe_liveConnection_returnsGreen()
{
await using var fx = await SqlitePollFixture.CreateAsync();
var probe = SqlDriverProbe.ForTest(fx.Factory, new SqliteDialect());
var r = await probe.ProbeAsync(fx.ConfigJson, TimeSpan.FromSeconds(5), CancellationToken.None);
r.Success.ShouldBeTrue();
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlDriverProbe : IDriverProbe`, `DriverType => DriverTypeNames.Sql`. Parse config via the SAME factory DTO shape + `JsonStringEnumConverter` (R2-11 factory parity, mirroring `ModbusDriverProbe`), resolve the connection string ref, open a connection under a linked CTS bounded by `timeout`, run `dialect.LivenessSql` (`SELECT 1`), return `DriverProbeResult(true, "SQL SELECT 1 OK", latency)`; catch → red result naming the failure. Never throws. `ForTest` injects factory+dialect for the SQLite path.
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlDriverProbe SELECT-1 liveness check"`
---
## Task 11: Host registration — `DriverTypeNames.Sql` + factory + probe + guard test
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` (project-ref `Driver.Sql` if not transitively present)
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs` is EXISTING — it asserts bidirectional parity between `DriverTypeNames` constants and the factory-registered set; this task makes it pass by adding both sides.
**TDD:**
1. **Failing step** — add `public const string Sql = "Sql";` to `DriverTypeNames` + include in `All`, but DON'T yet register the factory. Run `DriverTypeNamesGuardTests` → expect FAIL (constant with no registered factory).
2. **Impl** — in `DriverFactoryBootstrap.Register(...)` add `Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);`; in `AddOtOpcUaDriverProbes` add `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());` with a `using SqlProbe = Driver.Sql.SqlDriverProbe;` alias; add the `Driver.Sql` project-ref to the Host csproj. Default tier `DriverTier.A` (no explicit arg — SQL client is cross-platform managed, runs on macOS dev, so `ShouldStub()` needs no change).
3. Run guard test + `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect PASS.
4. Commit: `git commit -m "feat(sql): register Sql factory + probe in Host, add DriverTypeNames.Sql"`
---
## Task 12: Scaffold `Driver.Sql.Browser` project + register in slnx
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5, Task 6, Task 9, Task 10
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
**Steps:**
1. csproj refs `Commons` (`ZB.MOM.WW.OtOpcUa.Commons``IDriverBrowser`/`IBrowseSession`/`BrowseNode`) + `Driver.Sql.Contracts` + **`Driver.Sql`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine; `Microsoft.Data.SqlClient` comes transitively). This runtime-project ref is the deliberate deviation from `OpcUaClient.Browser` recorded in design §2 — add an XML comment on the ref citing the design so nobody "fixes" it. `InternalsVisibleTo` the `.Browser.Tests`.
2. Add to slnx under `/src/Drivers/`.
3. Build — expect PASS.
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql.Browser project"`
---
## Task 13: `SqlBrowseSession` — schema walk over the dialect catalog
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj` (+ slnx entry, + link `SqliteDialect.cs` from Sql.Tests)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs`
**TDD:**
1. **Failing test** — over a seeded SQLite DB + `SqliteDialect`: `RootAsync` yields schema folder(s); `ExpandAsync(schema)` yields the seeded tables; `ExpandAsync(table)` yields columns as leaves; `AttributesAsync(column)` yields the mapped `DriverDataType`:
```csharp
[Fact]
public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType()
{
await using var db = await SqliteBrowseFixture.CreateAsync(); // TagValues(tag_name TEXT, num_value REAL)
var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var tableNode = (await session.ExpandAsync(db.SchemaNodeId, default)).First(n => n.DisplayName.Contains("TagValues"));
var cols = await session.ExpandAsync(tableNode.NodeId, default);
cols.ShouldContain(c => c.DisplayName == "num_value" && c.IsLeaf);
var attrs = await session.AttributesAsync(cols.First(c => c.DisplayName == "num_value").NodeId, default);
attrs.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlBrowseSession : IBrowseSession` (match the `Commons.Browsing` interface shape used by `OpcUaClientBrowseSession`): `RootAsync`/`ExpandAsync(nodeId)`/`AttributesAsync(nodeId)` run the dialect catalog queries with bound `@schema`/`@table` params; encode `NodeId` as `schema` / `schema.table` / `schema.table|column`; a `SemaphoreSlim _gate` serializes (one ADO.NET connection is not concurrent); per-call work bounded by the AdminUI's existing 20 s per-call timeout. Column leaf `AttributesAsync` returns `AttributeInfo(Name: column, DriverDataType: dialect.MapColumnType(dataType).ToString(), IsArray:false, SecurityClass:"ViewOnly")` (mirrors Galaxy two-stage pick).
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlBrowseSession schema-walk over dialect catalog"`
---
## Task 14: `SqlDriverBrowser` — IDriverBrowser open (env ref OR pasted literal, transient connection)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs`
**TDD:**
1. **Failing test**`OpenAsync` with a form JSON whose `connectionStringRef` resolves from env opens a session; an UNresolvable ref fails with a message naming the EXACT missing env var; a pasted literal connection string is used session-only (never persisted):
```csharp
[Fact]
public async Task Open_unresolvableRef_failsNamingExactEnvVar()
{
var browser = new SqlDriverBrowser();
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default));
ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging");
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlDriverBrowser : IDriverBrowser`, `DriverType => DriverTypeNames.Sql`. `OpenAsync(configJson, ct)`: deserialize with the same `JsonStringEnumConverter` options; resolve `connectionStringRef` via a direct env read (`Sql__ConnectionStrings__<ref>`) IN THE ADMINUI PROCESS — on miss throw an actionable message naming the exact missing env var (design §4.4); accept an optional pasted literal `connectionString` for ad-hoc browse (session-only, **never persisted, never logged, no `_lastConfigJson` cached field**); select `SqlServerDialect` (P1); open ONE transient `DbConnection`, return a `SqlBrowseSession`. Reuses the AdminUI `BrowseSessionRegistry` + reaper + idle TTL unchanged.
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): SqlDriverBrowser transient-connection open with env-ref + literal"`
---
## Task 15: AdminUI browser DI + `SqlAddressPickerBody.razor` (picker body)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (add `services.AddSingleton<IDriverBrowser, SqlDriverBrowser>();` beside the OpcUaClient/Galaxy lines ~:75)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (project-ref `Driver.Sql.Browser`)
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddressPickers/SqlAddressPickerBody.razor` (reuse `DriverBrowseTree.razor` for the tree + an attribute side-panel + model/selector fields; gated by the existing `DriverOperator` policy; manual entry retained)
**Steps (no unit test — Razor is live-verified in Task 21):**
1. Register the bespoke `IDriverBrowser` (the universal-browser DI line is NOT added for `Sql` — its `CanBrowse` is false and bespoke wins by construction).
2. `SqlAddressPickerBody.razor` — thin body: `DriverBrowseTree` in picker mode + attribute side-panel; on column pick, compose the `TagConfig` blob (DisplayName default `table.column`, prefill `type` from `AttributesAsync`, operator picks model + fills key/row selector; `SecurityClass=ViewOnly`).
3. `dotnet build` — expect PASS.
4. Commit: `git commit -m "feat(sql): AdminUI browser DI + Sql address picker body"`
---
## Task 16: Env-gated central-SQL integration fixture (`Driver.Sql.IntegrationTests`)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 13, Task 14, Task 15, Task 17
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj` (+ slnx entry)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs`
**TDD:**
1. **Failing/skipping test** — against the always-on central SQL Server (`10.100.0.35,14330`), seed a `SqlPollFixture` database with the two sample tables (KeyValue + wide-row), then validate the real `Microsoft.Data.SqlClient` path (read round-trip, `INFORMATION_SCHEMA` browse, `CommandTimeout`/cancellation, pooling). **Env-gated** via `SQL_TEST_ENDPOINT` / a full `Sql__ConnectionStrings__Fixture` — absent ⇒ skip cleanly (use `Assert.Skip(...)` / xunit.v3 `[Fact(Skip=...)]` dynamic skip), like every other `*.IntegrationTests`:
```csharp
[Fact]
public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue()
{
var connStr = Environment.GetEnvironmentVariable("Sql__ConnectionStrings__Fixture");
Assert.SkipWhen(string.IsNullOrEmpty(connStr), "Sql__ConnectionStrings__Fixture not set — offline skip.");
await using var fx = await SqlPollServerFixture.EnsureSeededAsync(connStr!);
var driver = SqlDriver.ForProduction(connStr!, new SqlServerDialect(), fx.AuthoredRawTags);
await driver.InitializeAsync(fx.ConfigJson, default);
var snaps = await driver.ReadAsync(new[] { "Line1.Speed" }, default);
snaps[0].StatusCode.ShouldBe(0u);
}
```
2. Run offline — expect SKIP (green).
3. **Impl** — the fixture (create-if-missing DB + tables + seed rows via `Microsoft.Data.SqlClient`) and the read tests. **The seed DB is `SqlPollFixture`, NOT `ConfigDb`** — the shared central server hosts ConfigDb; the fixture uses its own database on that server.
4. Commit: `git commit -m "test(sql): env-gated central-SQL integration fixture + read round-trip"`
---
## Task 17: Injection regression test (bind-harmless value / reject identifier, never execute)
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 13, Task 14, Task 15, Task 16
**Files:**
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs`
**TDD:**
1. **Failing test** (offline, SQLite fixture) — a malicious `keyValue` (`'; DROP TABLE TagValues; --`) reads harmlessly as a bound param and the seed table still exists afterward; a malicious `table`/`column` identifier that is not catalog-valid is REJECTED (tag → `BadNodeIdUnknown`), never executed:
```csharp
[Fact]
public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives()
{
await using var fx = await SqlitePollFixture.CreateAsync();
var reader = fx.NewReader();
var snaps = await reader.ReadAsync(new[] { fx.RefWithKeyValue("'; DROP TABLE TagValues; --") }, default);
snaps[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // no such key — bound, not executed
(await fx.TableRowCountAsync("TagValues")).ShouldBeGreaterThan(0); // table intact
}
```
2. Run — expect FAIL (until reader binds correctly; it should already PASS from Task 7 if binding is right — this test LOCKS the guarantee).
3. **Impl** — none if Task 7 binds correctly; otherwise fix. Add a review-checklist note: "any code path building SQL by string-concatenating a tag field is a defect."
4. Commit: `git commit -m "test(sql): injection regression — bind values, reject unknown identifiers"`
---
## Task 18: Blackhole / timeout live-gate against a DEDICATED mssql container
**Classification:** high-risk (frozen-peer resilience — the single highest-value integration test)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml` (a dedicated disposable `mssql` stack)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs`
> **CRITICAL:** this gate `docker pause`s a **dedicated** `mcr.microsoft.com/mssql/server` container it owns — **NEVER** the shared central SQL Server on `10.100.0.35,14330`, which hosts `ConfigDb` for the whole rig (design §9). The compose stack is deployed via `lmxopcua-fix sync`; `lmxopcua-fix` applies the `project=lmxopcua` label host-side.
**TDD:**
1. **Failing/skipping test** — mirror the R2-01 S7 blackhole (`S7_1500ConnectTimeoutOutageTests`): start reading against the dedicated mssql, then `docker pause` it mid-poll; assert the next read surfaces `BadTimeout` within `operationTimeout` + a small margin (client-side linked-CTS cancellation, not the poll thread wedging), the driver degrades, and the poll loop backs off; `docker unpause` recovers. Env-gated (`SQL_BLACKHOLE_ENDPOINT`) — offline skip:
```csharp
[Fact]
public async Task PausedServer_surfacesBadTimeout_withinDeadline_notWedged()
{
var ep = Environment.GetEnvironmentVariable("SQL_BLACKHOLE_ENDPOINT");
Assert.SkipWhen(string.IsNullOrEmpty(ep), "SQL_BLACKHOLE_ENDPOINT not set — offline skip.");
// ... start driver, docker pause <dedicated-container>, read, assert BadTimeout within ~operationTimeout, unpause ...
}
```
2. Run offline — expect SKIP.
3. **Impl** — dedicated `docker-compose.yml` (`mssql` service, own container name, `restart: "no"`), `seed.sql` (the two sample tables), and the test that shells `docker pause`/`docker unpause` against the OWNED container. Assert the wall-clock bound (the frozen-peer contract): `CommandTimeout` (server-side backstop) + `ExecuteReaderAsync(linkedCt)` bounded by `operationTimeout` (client-side real cancellation) both fire.
4. Commit: `git commit -m "test(sql): blackhole/timeout live-gate on a dedicated mssql container"`
---
## Task 19: AdminUI typed editor model + validator (timeout-order rule, string enums)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16, Task 17, Task 18
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (add `[DriverTypeNames.Sql] = typeof(...SqlTagConfigEditor)`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs` (add `[DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate()`)
- Test: `tests/.../AdminUI.Tests/.../SqlTagConfigModelTests.cs` (locate the existing AdminUI tag-editor test project; mirror `ModbusTagConfigModel` tests)
**TDD:**
1. **Failing test**`FromJson``ToJson` round-trips the model, preserves unknown keys, writes enums (`model`/`type`) as NAME strings not numbers; `Validate()` rejects a WideRow without a row selector and enforces the driver-level timeout-order rule surrogate at the tag layer if the tag carries timeouts (the primary timeout-order check is at the driver page — the model validates model/selector shape):
```csharp
[Fact]
public void ToJson_writesModelAndTypeAsStrings_preservesUnknownKeys()
{
var m = SqlTagConfigModel.FromJson("""{"driver":"Sql","model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Float64","customX":1}""");
var json = m.ToJson();
json.ShouldContain("\"KeyValue\"");
json.ShouldContain("\"Float64\"");
json.ShouldContain("customX"); // unknown key survives load→save
json.ShouldNotContain("\"model\":0");
}
```
2. Run — expect FAIL.
3. **Minimal impl**`SqlTagConfigModel` (thin, over `TagConfigJson.ParseOrNew`, preserving the `_bag`): `Model`, `Table`, `KeyColumn`/`KeyValue`/`ValueColumn`/`TimestampColumn`, `ColumnName` + `RowSelector`, `Type` (`DriverDataType?`), `FromJson`/`ToJson` (enums as name strings via `TagConfigJson.Set`) `/Validate` (model discriminator ⇒ required-field shape; WideRow needs a selector). Register in `TagConfigEditorMap` + `TagConfigValidator`. **Enum-serialization trap (systemic):** `model`/`type` MUST round-trip as strings on both sides — the editor `ToJson` and the factory `SqlEquipmentTagParser` both use string enums; add the assertion above.
4. Run — expect PASS.
5. Commit: `git commit -m "feat(sql): typed AdminUI Sql tag-config model + validator (string enums)"`
---
## Task 20: `SqlTagConfigEditor.razor` (thin shell over the model)
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor`
**Steps (Razor — live-verified in Task 21):**
1. Copy the Modbus editor template; bind over `SqlTagConfigModel`: a `Model` dropdown (KeyValue/WideRow) that shows the matching field group, `Table`, the key/value/timestamp fields (KeyValue) OR `ColumnName` + row-selector fields (WideRow), and a `Type` override dropdown. `@bind` string component params need the `@` prefix (the Global-UNS gotcha).
2. `dotnet build` — expect PASS.
3. Commit: `git commit -m "feat(sql): SqlTagConfigEditor razor shell"`
---
## Task 21: Live `/run` verification on docker-dev (picker + editor + deploy + read)
**Classification:** trivial (procedure — no product code; may surface fix-forward tasks)
**Estimated implement time:** ~5 min (verification, excluding any bugs found)
**Parallelizable with:** none (last)
**Files:** none (verification only; any defect becomes a new fix-forward task)
**Procedure (docker-dev rig, AdminUI auto-authenticated at `http://localhost:9200` — login disabled, so drive it yourself, don't defer to the user):**
1. Set `Sql__ConnectionStrings__DevSql` on the docker-dev central + driver nodes pointing at a dev SQL Server + seeded table (the dedicated mssql from Task 18, or the central-SQL fixture DB). Rebuild BOTH central-1/central-2 (`:9200` round-robins).
2. In `/raw`, add a `Sql` driver + device (paste a literal connection string or use the ref); Test-Connect → green (`SELECT 1`).
3. Open the Sql address picker → browse schema→table→column → commit a KeyValue tag and a WideRow tag; confirm the typed editor renders and validates.
4. Reference the raw tags into an equipment on `/uns`; deploy via `POST :9200/api/deployments` (X-Api-Key) or the deploy button; confirm the deployment seals green.
5. Read via Client.CLI (`read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>"`) — confirm the live SQL value + quality + source timestamp.
6. Record the run outcome in the plan's completion note. If anything is deploy-inert / mis-bound, open a fix-forward task (Razor binding + deploy-inertness bugs pass unit tests + review — this live gate is the real proof).
---
## Deferred / out of scope (pointers to the design, NOT executable tasks here)
These are recorded so nobody re-derives them; each is a later phase in `docs/plans/2026-07-15-sql-poll-driver-design.md`. The `ISqlDialect` seam is built in from day one (Task 4) precisely so the provider tail below is additive and cheap.
- **Named-query model** (design §5.4, §3.6(c) / design-P3) — the arbitrary-`SELECT` escape hatch (`queries: {...}` + a tag referencing a query by name + projection). Not built in v1; the `SqlTagModel.Query` enum member exists but the reader/planner path is deferred.
- **PostgreSQL + ODBC dialects** (design §2.2, §8.5 / design-P3) — `PostgresDialect` (`Npgsql`) + `OdbcDialect` (`System.Data.Odbc`), each a NEW gated `PackageVersion`. v1 constructs only `SqlServerDialect`; the other `SqlProvider` members throw "provider not available in this build."
- **MySQL/MariaDB + native Oracle** (design §10 / design-P5) — `MySqlConnector` + an `ALL_TAB_COLUMNS` Oracle dialect. Demand-driven.
- **Write mode (`IWritable`)** (design §3.4 / design-P4) — opt-in, triple-gated (`WriteOperate` role + driver `allowWrites` master switch + `TagConfig.writable`), parameterized UPSERT/StoredProc, `WriteIdempotent` retry policy. v1 is read-only; `SecurityClass=ViewOnly` everywhere and `allowWrites` defaults `false`.
- **Split-role env-parity operational note** (design §4.4) — admin nodes must carry the same `Sql__ConnectionStrings__<ref>` env vars as driver nodes for any browseable ref; the browser-open failure names the exact missing env var. This is a deployment/runbook item, not a code task.
@@ -0,0 +1,28 @@
{
"planPath": "docs/plans/2026-07-24-sql-poll-driver.md",
"tasks": [
{"id": 0, "subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx", "status": "pending"},
{"id": 1, "subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx", "status": "pending", "blockedBy": [0]},
{"id": 4, "subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)", "status": "pending", "blockedBy": [1, 3]},
{"id": 5, "subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)", "status": "pending", "blockedBy": [2, 4]},
{"id": 6, "subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture", "status": "pending", "blockedBy": [3, 4]},
{"id": 7, "subject": "Task 7: SqlPollReader core — grouped read, bounded deadline, ordered slice-back", "status": "pending", "blockedBy": [5, 6]},
{"id": 8, "subject": "Task 8: SqlDriver shell — IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe", "status": "pending", "blockedBy": [7]},
{"id": 9, "subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard", "status": "pending", "blockedBy": [8]},
{"id": 10, "subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)", "status": "pending", "blockedBy": [4, 6]},
{"id": 11, "subject": "Task 11: Host registration — DriverTypeNames.Sql + factory + probe + guard test", "status": "pending", "blockedBy": [9, 10]},
{"id": 12, "subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx", "status": "pending", "blockedBy": [3]},
{"id": 13, "subject": "Task 13: SqlBrowseSession — schema walk over dialect catalog", "status": "pending", "blockedBy": [4, 12, 6]},
{"id": 14, "subject": "Task 14: SqlDriverBrowser — env-ref/literal transient-connection open", "status": "pending", "blockedBy": [13]},
{"id": 15, "subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor", "status": "pending", "blockedBy": [14]},
{"id": 16, "subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip", "status": "pending", "blockedBy": [8]},
{"id": 17, "subject": "Task 17: Injection regression test (bind value / reject identifier)", "status": "pending", "blockedBy": [7]},
{"id": 18, "subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container", "status": "pending", "blockedBy": [16]},
{"id": 19, "subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)", "status": "pending", "blockedBy": [1, 11]},
{"id": 20, "subject": "Task 20: SqlTagConfigEditor.razor shell", "status": "pending", "blockedBy": [19]},
{"id": 21, "subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)", "status": "pending", "blockedBy": [11, 15, 20]}
],
"lastUpdated": "2026-07-24"
}
@@ -130,6 +130,14 @@ public sealed class ModbusDriverOptions
/// </summary>
public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;
/// <summary>
/// Wire transport selector. <see cref="ModbusTransportMode.Tcp"/> (default) is the
/// historical Modbus TCP/MBAP framing. <see cref="ModbusTransportMode.RtuOverTcp"/>
/// frames Modbus RTU PDUs (CRC16, no MBAP header) over the same TCP socket — for
/// serial-to-Ethernet gateways that bridge RS-485 without re-encapsulating to MBAP.
/// </summary>
public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;
/// <summary>
/// When <c>true</c>, the driver suppresses redundant writes: if the most recent
/// successful write to a tag carried value V and a new write of V arrives, the second
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Wire transport for a Modbus driver instance. <see cref="Tcp"/> = Modbus/TCP (MBAP + TxId);
/// <see cref="RtuOverTcp"/> = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to
/// a serial→Ethernet gateway. Default <see cref="Tcp"/> — existing configs omit the field.
/// A direct-serial <c>Rtu</c> member is intentionally NOT reserved here (descoped 2026-07-15);
/// do not number-squat it.
/// </summary>
public enum ModbusTransportMode
{
/// <summary>Modbus/TCP — 7-byte MBAP header + transaction id (the historical default).</summary>
Tcp,
/// <summary>RTU framing (<c>[addr][PDU][CRC-16]</c>) tunnelled over a socket to a serial→Ethernet gateway.</summary>
RtuOverTcp,
}
@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// CRC-16/MODBUS helper: reflected polynomial <c>0xA001</c>, initial value <c>0xFFFF</c>.
/// On the wire the CRC is appended low byte first, high byte second — <see cref="Append"/>
/// does that; <see cref="Compute"/> just returns the 16-bit value. Byte-stream-agnostic:
/// no socket or framing knowledge lives here.
/// </summary>
public static class ModbusCrc
{
/// <summary>Computes the CRC-16/MODBUS checksum over <paramref name="data"/>.</summary>
/// <param name="data">The bytes to checksum (e.g. the RTU frame minus the trailing CRC).</param>
/// <returns>The 16-bit CRC value.</returns>
public static ushort Compute(ReadOnlySpan<byte> data)
{
ushort crc = 0xFFFF;
foreach (var b in data)
{
crc ^= b;
for (var i = 0; i < 8; i++)
{
var carry = (crc & 0x0001) != 0;
crc >>= 1;
if (carry)
{
crc ^= 0xA001;
}
}
}
return crc;
}
/// <summary>
/// Returns <paramref name="body"/> with its CRC-16/MODBUS appended, low byte first.
/// </summary>
/// <param name="body">The frame body to checksum.</param>
/// <returns>A new array: <paramref name="body"/> followed by <c>[crc &amp; 0xFF, crc &gt;&gt; 8]</c>.</returns>
public static byte[] Append(ReadOnlySpan<byte> body)
{
var crc = Compute(body);
var result = new byte[body.Length + 2];
body.CopyTo(result);
result[^2] = (byte)(crc & 0xFF);
result[^1] = (byte)(crc >> 8);
return result;
}
}
@@ -94,7 +94,8 @@ public sealed class ModbusDriver
/// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
/// <param name="options">Driver configuration options.</param>
/// <param name="driverInstanceId">Unique identifier for this driver instance.</param>
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to ModbusTcpTransport.</param>
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to
/// <see cref="ModbusTransportFactory.Create"/>, which honours <see cref="ModbusDriverOptions.Transport"/>.</param>
/// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
@@ -107,11 +108,7 @@ public sealed class ModbusDriver
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_transportFactory = transportFactory
?? (o => new ModbusTcpTransport(
o.Host, o.Port, o.Timeout, o.AutoReconnect,
keepAlive: o.KeepAlive,
idleDisconnect: o.IdleDisconnectTimeout,
reconnect: o.Reconnect));
?? (o => ModbusTransportFactory.Create(o));
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -74,6 +74,8 @@ public static class ModbusDriverFactoryExtensions
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"),
Transport = dto.Transport is null ? ModbusTransportMode.Tcp
: ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport"),
AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null,
AutoReconnect = dto.AutoReconnect ?? true,
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus
@@ -159,6 +161,8 @@ public static class ModbusDriverFactoryExtensions
public string? Family { get; init; }
/// <summary>Gets or sets the Melsec subfamily.</summary>
public string? MelsecSubFamily { get; init; }
/// <summary>Gets or sets the wire transport mode (Tcp / RtuOverTcp). Null defaults to Tcp.</summary>
public string? Transport { get; init; }
/// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary>
public int? AutoProhibitReprobeMs { get; init; }
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
@@ -29,15 +29,19 @@ public sealed class ModbusDriverProbe : IDriverProbe
/// <inheritdoc />
public string DriverType => "Modbus";
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout, all read
/// from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver factory parses, so
/// <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).</summary>
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout + the wire
/// transport mode, all read from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver
/// factory parses, so <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the
/// OpcUaClient parity rule) and an <c>RtuOverTcp</c>-authored config probes over RTU framing —
/// matching how the driver will actually run.</summary>
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout, ModbusTransportMode Transport);
/// <summary>Parse the driver config JSON into a <see cref="ProbeTarget"/> using the factory DTO shape.
/// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective
/// timeout mirrors the factory (<c>TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)</c>); when the config
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used.</summary>
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used. <c>Transport</c>
/// mirrors the factory's null-defaults-to-Tcp convention (<see cref="ModbusDriverFactoryExtensions"/>);
/// an unrecognized value is reported as a probe-target error rather than silently falling back.</summary>
/// <param name="configJson">The driver config JSON (factory DTO shape).</param>
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</param>
/// <returns>The parsed target, or a null target with an error string.</returns>
@@ -52,8 +56,15 @@ public sealed class ModbusDriverProbe : IDriverProbe
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
return (null, "Config has no host/port to probe.");
var transportMode = ModbusTransportMode.Tcp;
if (dto.Transport is not null && !Enum.TryParse(dto.Transport, ignoreCase: true, out transportMode))
{
return (null, $"Config has unknown Transport '{dto.Transport}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<ModbusTransportMode>())}");
}
var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout;
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout), null);
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout, transportMode), null);
}
/// <inheritdoc />
@@ -72,9 +83,17 @@ public sealed class ModbusDriverProbe : IDriverProbe
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
// Phase 1 — TCP connect (using ModbusTcpTransport which handles IPv4 preference).
// Phase 1 — TCP connect, over whichever transport the config's Transport mode selects
// (ModbusTransportFactory is the single mapping point — same switch the live driver uses).
// autoReconnect=false: this is a one-shot probe, no retry loops.
var transport = new ModbusTcpTransport(host, port, timeout, autoReconnect: false);
var transport = ModbusTransportFactory.Create(new ModbusDriverOptions
{
Host = host,
Port = port,
Timeout = timeout,
Transport = t.Transport,
AutoReconnect = false,
});
await using (transport.ConfigureAwait(false))
{
try
@@ -0,0 +1,187 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Modbus RTU framing: builds a request ADU (<c>[unitId] + PDU + CRC</c>) and deframes a
/// response back to its bare PDU. Byte-stream-agnostic — it operates on a <see cref="Stream"/>
/// and knows nothing about sockets or serial ports, so a future serial transport can reuse it.
/// </summary>
/// <remarks>
/// <para>
/// The single genuinely-new correctness risk of the RTU path: an RTU frame carries <b>no
/// length field</b> (unlike TCP's MBAP header), so the response size must be derived from
/// the function code. After reading <c>addr(1)</c> + <c>fc(1)</c>, the remaining byte count
/// is one of three shapes:
/// </para>
/// <list type="table">
/// <listheader><term>Shape</term><description>Trailing bytes after <c>addr,fc</c></description></listheader>
/// <item>
/// <term>Exception (<c>fc &amp; 0x80</c>)</term>
/// <description><c>excCode(1)</c> + <c>CRC(2)</c></description>
/// </item>
/// <item>
/// <term>Read (FC 01/02/03/04)</term>
/// <description><c>byteCount(1)</c> then <c>byteCount</c> data bytes + <c>CRC(2)</c></description>
/// </item>
/// <item>
/// <term>Write echo (FC 05/06/15/16)</term>
/// <description>fixed <c>4</c> bytes + <c>CRC(2)</c></description>
/// </item>
/// </list>
/// <para>
/// The trailing CRC is validated over <c>[addr, ...pdu]</c> (everything except the two CRC
/// bytes) <b>before</b> the frame is interpreted — so a corrupt exception frame surfaces as a
/// desync, never as a bogus <see cref="ModbusException"/>. A CRC mismatch or a short read
/// (truncation / stream closed mid-frame) throws <see cref="ModbusTransportDesyncException"/>;
/// a CRC-valid exception PDU throws <see cref="ModbusException"/> carrying the original
/// function code (<c>fc &amp; 0x7F</c>) and exception code.
/// </para>
/// <para>
/// Once the CRC passes, the response's address byte is validated against the addressed unit.
/// On an RS-485 multi-drop bus a CRC-valid reply from the <b>wrong</b> slave would otherwise be
/// silently accepted as the addressed unit's data; such a frame is a unit-mismatch
/// <see cref="ModbusTransportDesyncException"/>. This ordering is deliberate — a corrupt frame
/// stays a CRC/desync failure, and only a coherent-but-mis-addressed frame becomes a
/// unit-mismatch desync.
/// </para>
/// </remarks>
public static class ModbusRtuFraming
{
/// <summary>
/// Builds an RTU request ADU: the unit id, the PDU, and the trailing CRC-16/MODBUS
/// (appended low byte first).
/// </summary>
/// <param name="unitId">The RTU slave/unit address.</param>
/// <param name="pdu">The bare PDU (function code + data).</param>
/// <returns>A new array: <c>[unitId, ...pdu, crcLo, crcHi]</c>.</returns>
public static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)
{
var frame = new byte[1 + pdu.Length];
frame[0] = unitId;
pdu.CopyTo(frame.AsSpan(1));
return ModbusCrc.Append(frame);
}
/// <summary>
/// Reads a single RTU response frame from <paramref name="stream"/> and returns its bare PDU
/// (<c>[fc, ...data]</c>), with the leading unit-id and trailing CRC stripped.
/// </summary>
/// <param name="stream">The byte stream to read the response from.</param>
/// <param name="expectedUnit">
/// The unit id the request was addressed to. The response's address byte is validated against
/// it after the CRC passes; a mismatch is a unit-mismatch desync.
/// </param>
/// <param name="ct">A token to cancel the read.</param>
/// <returns>The bare response PDU (function code followed by its data).</returns>
/// <exception cref="ModbusTransportDesyncException">
/// The frame was truncated (stream closed mid-frame), its trailing CRC did not validate, or its
/// address byte did not match <paramref name="expectedUnit"/>.
/// </exception>
/// <exception cref="ModbusException">
/// The frame was a CRC-valid Modbus exception PDU (high bit set on the function code).
/// </exception>
public static async Task<byte[]> ReadResponsePduAsync(
Stream stream, byte expectedUnit, CancellationToken ct)
{
// Header: addr(1) + fc(1). The function code selects how many more bytes to read.
var header = new byte[2];
await ReadExactlyAsync(stream, header, ct).ConfigureAwait(false);
var fc = header[1];
int trailing; // bytes after addr+fc, up to and including the 2 CRC bytes.
if ((fc & 0x80) != 0)
{
// Exception frame: excCode(1) + CRC(2).
trailing = 1 + 2;
}
else if (fc is 0x01 or 0x02 or 0x03 or 0x04)
{
// Read response: byteCount(1) then byteCount data bytes + CRC(2).
var bc = new byte[1];
await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
var byteCount = bc[0];
var rest = new byte[byteCount + 2];
await ReadExactlyAsync(stream, rest, ct).ConfigureAwait(false);
return ValidateAndStrip(expectedUnit, header, bc, rest);
}
else
{
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). A future
// variable-length response FC outside 01-04 (e.g. FC23) would be mis-sized here and
// surface as a desync — revisit the FC-shape table if the driver starts emitting one.
trailing = 4 + 2;
}
var tail = new byte[trailing];
await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false);
return ValidateAndStrip(expectedUnit, header, ReadOnlySpan<byte>.Empty, tail);
}
/// <summary>
/// Assembles the full frame from its pieces, validates the trailing CRC over everything
/// except the CRC bytes, then validates the response address against
/// <paramref name="expectedUnit"/> and strips <c>addr</c> + <c>CRC</c> to return the bare PDU.
/// A CRC-valid exception PDU throws <see cref="ModbusException"/>.
/// </summary>
private static byte[] ValidateAndStrip(
byte expectedUnit, ReadOnlySpan<byte> header, ReadOnlySpan<byte> middle, ReadOnlySpan<byte> tail)
{
// Reassemble the whole frame: header(addr,fc) + middle(optional byteCount) + tail.
var frame = new byte[header.Length + middle.Length + tail.Length];
header.CopyTo(frame);
middle.CopyTo(frame.AsSpan(header.Length));
tail.CopyTo(frame.AsSpan(header.Length + middle.Length));
// CRC covers everything except the trailing 2 CRC bytes.
var body = frame.AsSpan(0, frame.Length - 2);
var expected = ModbusCrc.Compute(body);
var actual = (ushort)(frame[^2] | (frame[^1] << 8));
if (actual != expected)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.CrcMismatch,
$"Modbus RTU CRC mismatch: computed {expected:X4} got {actual:X4}");
// Unit-address validation runs only AFTER the CRC passes: a corrupt frame is a CRC/desync
// failure, and only a coherent-but-mis-addressed frame (a CRC-valid reply from the wrong
// RS-485 slave) is a unit-mismatch desync. Never silently accept another unit's data.
var addr = frame[0];
if (addr != expectedUnit)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.UnitMismatch,
$"Modbus RTU unit mismatch: expected {expectedUnit} got {addr}");
// Bare PDU = frame minus leading addr and trailing CRC.
var pdu = body[1..].ToArray();
// Exception PDU: high bit set on the function code. The CRC already validated, so this is a
// coherent protocol-level error — surface the ORIGINAL function code (fc & 0x7F).
if ((pdu[0] & 0x80) != 0)
{
var fc = (byte)(pdu[0] & 0x7F);
var exCode = pdu[1];
throw new ModbusException(fc, exCode, $"Modbus exception fc={fc} code={exCode}");
}
return pdu;
}
/// <summary>
/// Fills <paramref name="buf"/> completely from <paramref name="stream"/>, throwing a
/// truncation desync if the stream ends first. Mirrors
/// <c>ModbusTcpTransport.ReadExactlyAsync</c>, but normalises the end-of-stream to a
/// <see cref="ModbusTransportDesyncException"/> so a length-less RTU frame that arrives
/// short is classified as a desync rather than a bare <see cref="EndOfStreamException"/>.
/// </summary>
private static async Task ReadExactlyAsync(Stream stream, byte[] buf, CancellationToken ct)
{
var read = 0;
while (read < buf.Length)
{
var n = await stream.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
if (n == 0)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
$"Modbus RTU frame truncated: expected {buf.Length} bytes, got {read}");
read += n;
}
}
}
@@ -0,0 +1,190 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Concrete Modbus RTU-over-TCP transport. Composes <see cref="ModbusSocketLifecycle"/> for the
/// connect / reconnect / keep-alive / idle machinery and <see cref="ModbusRtuFraming"/> for the
/// wire layout — the same lifecycle <see cref="ModbusTcpTransport"/> uses, but with CRC framing
/// instead of MBAP.
/// </summary>
/// <remarks>
/// <para>
/// Delta from <see cref="ModbusTcpTransport"/>: an RTU ADU is <c>[unitId][PDU][CRC]</c> with
/// <b>no MBAP header and no transaction id</b>. Because there is no TxId to correlate
/// interleaved responses, at most one transaction may be on the bus at a time — the
/// <see cref="_gate"/> single-flight is therefore <b>mandatory</b>, not merely a
/// diagnostics convenience.
/// </para>
/// <para>
/// Survives mid-transaction socket drops the same way the TCP transport does: a socket-level
/// failure (<see cref="IOException"/> / <see cref="SocketException"/> /
/// <see cref="EndOfStreamException"/>, and the <see cref="ModbusTransportDesyncException"/>
/// that derives from <see cref="IOException"/>) triggers a single reconnect-then-retry; a
/// second failure bubbles up so the driver's health surface reflects the real state.
/// </para>
/// <para>
/// Every transaction runs under a per-op deadline (a linked
/// <see cref="CancellationTokenSource.CancelAfter(TimeSpan)"/>, design §7 / R2-01) so a
/// frozen gateway can never wedge a poll: the deadline firing is normalised to a
/// <see cref="ModbusTransportDesyncException"/> and tears the socket down so the next attempt
/// reconnects. A <b>caller</b> cancellation is distinguished from that timeout and propagates
/// as a plain <see cref="OperationCanceledException"/> with no teardown.
/// </para>
/// <para>
/// The constructor is connection-free; all socket I/O happens in <see cref="ConnectAsync"/> /
/// <see cref="SendAsync"/>. The <see cref="ForTest"/> seam injects a pre-connected
/// <see cref="Stream"/> (an in-memory duplex fake) so the send/receive orchestration,
/// single-flight, and deadline can be exercised with no socket — in that path
/// <see cref="_life"/> is <see langword="null"/> and the idle / reconnect machinery is
/// skipped (a raw injected stream cannot reconnect).
/// </para>
/// </remarks>
public sealed class ModbusRtuOverTcpTransport : IModbusTransport
{
private readonly ModbusSocketLifecycle? _life;
private readonly Stream? _testStream;
private readonly TimeSpan _timeout;
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _disposed;
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpTransport"/> class.</summary>
/// <param name="host">The host address or hostname of the Modbus gateway.</param>
/// <param name="port">The TCP port of the Modbus gateway.</param>
/// <param name="timeout">The timeout for socket operations and the per-op response deadline.</param>
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
public ModbusRtuOverTcpTransport(
string host, int port, TimeSpan timeout, bool autoReconnect = true,
ModbusKeepAliveOptions? keepAlive = null,
TimeSpan? idleDisconnect = null,
ModbusReconnectOptions? reconnect = null)
{
_timeout = timeout;
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
}
private ModbusRtuOverTcpTransport(Stream testStream, TimeSpan timeout)
{
_timeout = timeout;
_testStream = testStream;
}
/// <summary>
/// Test seam: builds a transport over a pre-connected, in-memory duplex <paramref name="stream"/>,
/// bypassing <see cref="ConnectAsync"/> and the idle / reconnect machinery so the send/receive
/// orchestration, single-flight, and per-op deadline can be exercised with no real socket.
/// </summary>
/// <param name="stream">A pre-connected duplex stream standing in for the socket.</param>
/// <param name="timeout">The per-op response deadline.</param>
/// <returns>A transport driving <paramref name="stream"/> directly.</returns>
internal static ModbusRtuOverTcpTransport ForTest(Stream stream, TimeSpan timeout) => new(stream, timeout);
/// <inheritdoc />
public Task ConnectAsync(CancellationToken ct) =>
// The ForTest seam is handed a pre-connected stream — nothing to dial.
_life is null ? Task.CompletedTask : _life.ConnectAsync(ct);
/// <inheritdoc />
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
if (CurrentStream is null) throw new InvalidOperationException("Transport not connected");
// Single-flight: RTU carries no transaction id, so at most one transaction may be on the bus
// at a time — serialise every send/receive under the gate.
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
// Proactive idle-disconnect (real socket only): if the socket has been quiet longer than
// the configured threshold, tear it down + reconnect before this PDU lands. Defends
// against silent NAT / firewall reaping.
if (_life is not null && _life.ShouldReconnectForIdle())
{
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
}
try
{
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_life?.MarkSuccess();
return result;
}
catch (Exception ex) when (_life is not null && _life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
{
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
// configured), resend. Single retry — a second failure propagates so health/status
// reflect reality. Never reached in the ForTest seam (a raw injected stream has no
// lifecycle to reconnect).
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_life.MarkSuccess();
return result;
}
}
finally
{
_gate.Release();
}
}
/// <summary>The live stream: the lifecycle's <see cref="NetworkStream"/>, or the injected test stream.</summary>
private Stream? CurrentStream => _life?.Stream ?? _testStream;
/// <summary>
/// Executes exactly one RTU transaction on the current stream: build the ADU, write + flush,
/// read back one deframed response PDU. See the class remarks for the desync / timeout /
/// caller-cancel handling.
/// </summary>
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var stream = CurrentStream;
if (stream is null) throw new InvalidOperationException("Transport not connected");
// RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId.
var adu = ModbusRtuFraming.BuildAdu(unitId, pdu);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
try
{
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
// Framing owns the length-less RTU deframe + CRC + unit-address validation. A CRC-valid
// exception PDU throws ModbusException (socket still coherent — propagates, no teardown);
// truncation / CRC / unit-mismatch throw ModbusTransportDesyncException.
return await ModbusRtuFraming.ReadResponsePduAsync(stream, unitId, cts.Token).ConfigureAwait(false);
}
catch (ModbusTransportDesyncException)
{
// Framing violation: the stream is desynchronized — never reuse it.
if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
throw;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Per-op timeout fired (NOT the caller). The response is unknown / may still land later
// and corrupt the next read — tear the socket down and normalise as a desync so the
// reconnect retry / status mapping engages.
if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.Timeout,
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
}
}
/// <summary>Asynchronously disposes the transport and underlying socket resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
if (_life is not null) await _life.DisposeAsync().ConfigureAwait(false);
_gate.Dispose();
}
}
@@ -0,0 +1,221 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Owns the raw TCP socket lifecycle shared by every Modbus-over-TCP transport (MBAP and
/// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, geometric-backoff
/// reconnect, idle-disconnect tracking, and teardown. It exposes the current
/// <see cref="NetworkStream"/> so the composing transport can drive its own framing on top —
/// the lifecycle knows nothing about MBAP / RTU wire layout.
/// </summary>
/// <remarks>
/// <para>
/// Extracted verbatim from <see cref="ModbusTcpTransport"/> so the connect / reconnect /
/// keep-alive / idle behaviour is written once and composed by both transports. The
/// constructor is connection-free — all socket I/O happens in <see cref="ConnectAsync"/> /
/// <see cref="ConnectWithBackoffAsync"/>.
/// </para>
/// <para>
/// Why keep-alive matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
/// TCP keepalives per <c>docs/v2/dl205.md</c> §behavioral-oddities, so any NAT/firewall
/// between the gateway and PLC can silently close an idle socket after 2-5 minutes.
/// Enabling OS-level <c>SO_KEEPALIVE</c> lets the driver's own side detect a stuck socket
/// in reasonable time even when the application is mostly idle.
/// </para>
/// </remarks>
public sealed class ModbusSocketLifecycle : IAsyncDisposable
{
private readonly string _host;
private readonly int _port;
private readonly TimeSpan _timeout;
private readonly bool _autoReconnect;
private readonly ModbusKeepAliveOptions _keepAlive;
private readonly TimeSpan? _idleDisconnect;
private readonly ModbusReconnectOptions _reconnect;
private TcpClient? _client;
private NetworkStream? _stream;
private DateTime _lastSuccessUtc = DateTime.UtcNow;
/// <summary>Initializes a new instance of the <see cref="ModbusSocketLifecycle"/> class.</summary>
/// <param name="host">The host address or hostname of the Modbus server.</param>
/// <param name="port">The TCP port of the Modbus server.</param>
/// <param name="timeout">The timeout for socket operations.</param>
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
public ModbusSocketLifecycle(
string host, int port, TimeSpan timeout, bool autoReconnect = true,
ModbusKeepAliveOptions? keepAlive = null,
TimeSpan? idleDisconnect = null,
ModbusReconnectOptions? reconnect = null)
{
_host = host;
_port = port;
_timeout = timeout;
_autoReconnect = autoReconnect;
_keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
_idleDisconnect = idleDisconnect;
_reconnect = reconnect ?? new ModbusReconnectOptions();
}
/// <summary>Gets the current live <see cref="NetworkStream"/>, or <see langword="null"/> when not connected.</summary>
public NetworkStream? Stream => _stream;
/// <summary>Gets a value indicating whether auto-reconnect on socket failures is enabled.</summary>
public bool AutoReconnect => _autoReconnect;
/// <summary>Establishes a connection to the Modbus server, preferring IPv4.</summary>
/// <param name="ct">A cancellation token to observe for cancellation.</param>
/// <returns>A task representing the asynchronous connection operation.</returns>
public async Task ConnectAsync(CancellationToken ct)
{
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
// dialing the IPv4 address directly sidesteps that.
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
_client = new TcpClient(target.AddressFamily);
EnableKeepAlive(_client, _keepAlive);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
_stream = _client.GetStream();
_lastSuccessUtc = DateTime.UtcNow;
}
/// <summary>
/// Connect attempt with the configured geometric backoff. The first attempt fires after
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
/// </summary>
/// <param name="ct">A cancellation token to observe for cancellation.</param>
/// <returns>A task representing the asynchronous reconnect operation.</returns>
public async Task ConnectWithBackoffAsync(CancellationToken ct)
{
var delay = _reconnect.InitialDelay;
var attempt = 0;
while (true)
{
if (delay > TimeSpan.Zero)
await Task.Delay(delay, ct).ConfigureAwait(false);
try
{
await ConnectAsync(ct).ConfigureAwait(false);
return;
}
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
{
attempt++;
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
// pathological multipliers / long deployments.
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
if (attempt >= 10)
{
// Bail after 10 attempts to surface persistent failure to the caller. With
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
throw;
}
}
}
}
/// <summary>
/// Reports whether the socket has been idle longer than the configured idle-disconnect
/// threshold and should be proactively torn down + reconnected before the next transaction.
/// Always <see langword="false"/> when no idle-disconnect timeout is configured.
/// </summary>
/// <returns><see langword="true"/> when the idle threshold has been exceeded.</returns>
public bool ShouldReconnectForIdle() =>
_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value;
/// <summary>Records that a transaction just succeeded, resetting the idle-disconnect clock.</summary>
public void MarkSuccess() => _lastSuccessUtc = DateTime.UtcNow;
/// <summary>Tears down the current socket + stream, leaving the lifecycle ready to reconnect.</summary>
/// <returns>A task representing the asynchronous teardown operation.</returns>
public async Task TearDownAsync()
{
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort */ }
_stream = null;
try { _client?.Dispose(); } catch { }
_client = null;
}
/// <summary>
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
/// application-level probe still detects problems).
/// </summary>
private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts)
{
if (!opts.Enabled) return;
try
{
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
// A TimeSpan < 1s previously truncated to 0 via the int cast,
// which Windows / Linux interpret as "use the default" — silently defeating the
// configured keep-alive timing. Round up to at least 1 second so a sub-second
// configuration still produces a real keep-alive cadence. Negative values are
// also clamped to 1 to avoid surfacing as OS errors.
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
ClampToWholeSeconds(opts.Time));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
ClampToWholeSeconds(opts.Interval));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
}
catch { /* best-effort; older OSes may not expose the granular knobs */ }
}
/// <summary>
/// Cast a <see cref="TimeSpan"/> to a whole number of seconds with a
/// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
/// keep-alive timing into "use the default" on most OSes.
/// </summary>
/// <param name="ts">The timespan to clamp to whole seconds.</param>
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
internal static int ClampToWholeSeconds(TimeSpan ts)
{
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
return seconds < 1 ? 1 : seconds;
}
/// <summary>
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
/// PLC just returned exception 02 Illegal Data Address).
/// </summary>
/// <param name="ex">The exception to classify.</param>
/// <returns><see langword="true"/> when the exception is a socket-level failure.</returns>
internal static bool IsSocketLevelFailure(Exception ex) =>
ex is EndOfStreamException
|| ex is IOException
|| ex is SocketException
|| ex is ObjectDisposedException;
/// <summary>Asynchronously disposes the underlying socket + stream resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
try
{
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
}
catch { /* best-effort */ }
_client?.Dispose();
}
}
@@ -3,13 +3,19 @@ using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes
/// requests so at most one transaction is in-flight at a time — Modbus servers typically
/// support concurrent transactions, but the single-flight model keeps the wire trace
/// Concrete Modbus TCP transport. Wraps a single socket (owned by <see cref="ModbusSocketLifecycle"/>)
/// and serializes requests so at most one transaction is in-flight at a time — Modbus servers
/// typically support concurrent transactions, but the single-flight model keeps the wire trace
/// easy to diagnose and avoids interleaved-response correlation bugs.
/// </summary>
/// <remarks>
/// <para>
/// Owns the MBAP framing (<see cref="SendOnceAsync"/>: 7-byte header + transaction-id
/// pairing) and composes <see cref="ModbusSocketLifecycle"/> for the connect / reconnect /
/// keep-alive / idle-disconnect machinery — the same lifecycle the RTU-over-TCP transport
/// reuses with different framing.
/// </para>
/// <para>
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
@@ -26,19 +32,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// </remarks>
public sealed class ModbusTcpTransport : IModbusTransport
{
private readonly string _host;
private readonly int _port;
private readonly ModbusSocketLifecycle _life;
private readonly TimeSpan _timeout;
private readonly bool _autoReconnect;
private readonly ModbusKeepAliveOptions _keepAlive;
private readonly TimeSpan? _idleDisconnect;
private readonly ModbusReconnectOptions _reconnect;
private readonly SemaphoreSlim _gate = new(1, 1);
private TcpClient? _client;
private NetworkStream? _stream;
private ushort _nextTx;
private bool _disposed;
private DateTime _lastSuccessUtc = DateTime.UtcNow;
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
/// <param name="host">The host address or hostname of the Modbus server.</param>
@@ -54,84 +52,18 @@ public sealed class ModbusTcpTransport : IModbusTransport
TimeSpan? idleDisconnect = null,
ModbusReconnectOptions? reconnect = null)
{
_host = host;
_port = port;
_timeout = timeout;
_autoReconnect = autoReconnect;
_keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
_idleDisconnect = idleDisconnect;
_reconnect = reconnect ?? new ModbusReconnectOptions();
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
}
/// <inheritdoc />
public async Task ConnectAsync(CancellationToken ct)
{
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
// dialing the IPv4 address directly sidesteps that.
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
_client = new TcpClient(target.AddressFamily);
EnableKeepAlive(_client, _keepAlive);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
_stream = _client.GetStream();
_lastSuccessUtc = DateTime.UtcNow;
}
/// <summary>
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
/// application-level probe still detects problems).
/// </summary>
private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts)
{
if (!opts.Enabled) return;
try
{
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
// A TimeSpan < 1s previously truncated to 0 via the int cast,
// which Windows / Linux interpret as "use the default" — silently defeating the
// configured keep-alive timing. Round up to at least 1 second so a sub-second
// configuration still produces a real keep-alive cadence. Negative values are
// also clamped to 1 to avoid surfacing as OS errors.
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
ClampToWholeSeconds(opts.Time));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
ClampToWholeSeconds(opts.Interval));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
}
catch { /* best-effort; older OSes may not expose the granular knobs */ }
}
/// <summary>
/// Cast a <see cref="TimeSpan"/> to a whole number of seconds with a
/// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
/// keep-alive timing into "use the default" on most OSes.
/// </summary>
/// <param name="ts">The timespan to clamp to whole seconds.</param>
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
internal static int ClampToWholeSeconds(TimeSpan ts)
{
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
return seconds < 1 ? 1 : seconds;
}
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
/// <inheritdoc />
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
if (_stream is null) throw new InvalidOperationException("Transport not connected");
if (_life.Stream is null) throw new InvalidOperationException("Transport not connected");
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
@@ -140,27 +72,27 @@ public sealed class ModbusTcpTransport : IModbusTransport
// threshold, tear it down + reconnect before this PDU lands. Defends against silent
// NAT / firewall reaping where the socket looks alive locally but the upstream side
// dropped it minutes ago.
if (_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value)
if (_life.ShouldReconnectForIdle())
{
await TearDownAsync().ConfigureAwait(false);
await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
}
try
{
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_lastSuccessUtc = DateTime.UtcNow;
_life.MarkSuccess();
return result;
}
catch (Exception ex) when (_autoReconnect && IsSocketLevelFailure(ex))
catch (Exception ex) when (_life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
{
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
// configured), resend. Single retry — if it fails again, let it propagate so
// health/status reflect reality.
await TearDownAsync().ConfigureAwait(false);
await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_lastSuccessUtc = DateTime.UtcNow;
_life.MarkSuccess();
return result;
}
}
@@ -170,43 +102,6 @@ public sealed class ModbusTcpTransport : IModbusTransport
}
}
/// <summary>
/// Connect attempt with the configured geometric backoff. The first attempt fires after
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
/// </summary>
private async Task ConnectWithBackoffAsync(CancellationToken ct)
{
var delay = _reconnect.InitialDelay;
var attempt = 0;
while (true)
{
if (delay > TimeSpan.Zero)
await Task.Delay(delay, ct).ConfigureAwait(false);
try
{
await ConnectAsync(ct).ConfigureAwait(false);
return;
}
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
{
attempt++;
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
// pathological multipliers / long deployments.
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
if (attempt >= 10)
{
// Bail after 10 attempts to surface persistent failure to the caller. With
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
throw;
}
}
}
}
/// <summary>
/// Executes exactly one Modbus transaction on the current socket.
/// </summary>
@@ -230,7 +125,8 @@ public sealed class ModbusTcpTransport : IModbusTransport
/// </remarks>
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_stream is null) throw new InvalidOperationException("Transport not connected");
var stream = _life.Stream;
if (stream is null) throw new InvalidOperationException("Transport not connected");
var txId = ++_nextTx;
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
@@ -248,11 +144,11 @@ public sealed class ModbusTcpTransport : IModbusTransport
cts.CancelAfter(_timeout);
try
{
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
var header = new byte[7];
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
await ReadExactlyAsync(stream, header, cts.Token).ConfigureAwait(false);
var respTxId = (ushort)((header[0] << 8) | header[1]);
if (respTxId != txId)
throw new ModbusTransportDesyncException(
@@ -264,7 +160,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
$"Modbus response length too small: {respLen}");
var respPdu = new byte[respLen - 1];
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
await ReadExactlyAsync(stream, respPdu, cts.Token).ConfigureAwait(false);
// Exception PDU: function code has high bit set. This is a well-formed protocol-level
// error — the socket is still coherent, so it MUST propagate without teardown.
@@ -280,7 +176,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
catch (ModbusTransportDesyncException)
{
// Framing violation: the socket is desynchronized — never reuse it.
await TearDownAsync().ConfigureAwait(false);
await _life.TearDownAsync().ConfigureAwait(false);
throw;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
@@ -288,33 +184,13 @@ public sealed class ModbusTcpTransport : IModbusTransport
// Per-op timeout fired (NOT the caller). The response is unknown / may still land later
// and corrupt the next read — tear the socket down and normalize as a desync so the
// reconnect retry / status mapping engages.
await TearDownAsync().ConfigureAwait(false);
await _life.TearDownAsync().ConfigureAwait(false);
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.Timeout,
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
}
}
/// <summary>
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
/// PLC just returned exception 02 Illegal Data Address).
/// </summary>
private static bool IsSocketLevelFailure(Exception ex) =>
ex is EndOfStreamException
|| ex is IOException
|| ex is SocketException
|| ex is ObjectDisposedException;
private async Task TearDownAsync()
{
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort */ }
_stream = null;
try { _client?.Dispose(); } catch { }
_client = null;
}
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
{
var read = 0;
@@ -332,12 +208,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
{
if (_disposed) return;
_disposed = true;
try
{
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
}
catch { /* best-effort */ }
_client?.Dispose();
await _life.DisposeAsync().ConfigureAwait(false);
_gate.Dispose();
}
}
@@ -1,9 +1,10 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Raised when a Modbus TCP transaction leaves the single-flight socket in an unknown /
/// Raised when a Modbus transaction leaves the single-flight socket in an unknown /
/// desynchronized state — a per-op response timeout, or a framing violation (transaction-id
/// mismatch or truncated MBAP length). Unlike a Modbus <em>exception PDU</em> (a well-formed
/// mismatch, truncated frame, RTU CRC mismatch, or RTU unit-address mismatch). Unlike a Modbus
/// <em>exception PDU</em> (a well-formed
/// protocol-level error where the socket is still coherent and the request must simply
/// propagate), a desync means bytes may still be in flight or half-read, so the socket is
/// unusable and MUST be torn down before this is thrown.
@@ -27,8 +28,20 @@ public sealed class ModbusTransportDesyncException : IOException
/// <summary>The response MBAP transaction id did not match the request's.</summary>
TxIdMismatch,
/// <summary>The response MBAP length field was below the mandatory minimum (truncated frame).</summary>
/// <summary>
/// The frame ended before it was complete — the MBAP length field was below the mandatory
/// minimum (TCP), or the stream closed mid-frame while reading a length-less RTU frame.
/// </summary>
TruncatedFrame,
/// <summary>The RTU frame's trailing CRC-16 did not validate over the received bytes.</summary>
CrcMismatch,
/// <summary>
/// A CRC-valid RTU frame carried a slave/unit address other than the one addressed — on an
/// RS-485 multi-drop bus, a reply from the wrong slave.
/// </summary>
UnitMismatch,
}
/// <summary>Gets the reason the socket was classified desynchronized.</summary>
@@ -0,0 +1,43 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Single mapping point from <see cref="ModbusDriverOptions"/> to the concrete
/// <see cref="IModbusTransport"/> its <see cref="ModbusDriverOptions.Transport"/> selects.
/// Consumed by both <see cref="ModbusDriver"/>'s default transport-factory closure and the
/// AdminUI Test-Connect probe, so the <see cref="ModbusTransportMode"/> switch lives in
/// exactly one place.
/// </summary>
public static class ModbusTransportFactory
{
/// <summary>
/// Builds the transport <paramref name="options"/>.<see cref="ModbusDriverOptions.Transport"/>
/// selects, threading every shared connection setting (Host/Port/Timeout/AutoReconnect/
/// KeepAlive/IdleDisconnectTimeout/Reconnect) through to whichever concrete transport is built.
/// </summary>
/// <param name="options">Driver configuration options.</param>
/// <returns>A <see cref="ModbusRtuOverTcpTransport"/> when <see cref="ModbusTransportMode.RtuOverTcp"/>
/// is selected; a <see cref="ModbusTcpTransport"/> when <see cref="ModbusTransportMode.Tcp"/> is selected.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="options"/>.<see cref="ModbusDriverOptions.Transport"/> is not a recognized
/// <see cref="ModbusTransportMode"/> member — fail loudly rather than silently falling back to TCP/MBAP.
/// </exception>
public static IModbusTransport Create(ModbusDriverOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return options.Transport switch
{
ModbusTransportMode.RtuOverTcp => new ModbusRtuOverTcpTransport(
options.Host, options.Port, options.Timeout, options.AutoReconnect,
keepAlive: options.KeepAlive,
idleDisconnect: options.IdleDisconnectTimeout,
reconnect: options.Reconnect),
ModbusTransportMode.Tcp => new ModbusTcpTransport(
options.Host, options.Port, options.Timeout, options.AutoReconnect,
keepAlive: options.KeepAlive,
idleDisconnect: options.IdleDisconnectTimeout,
reconnect: options.Reconnect),
_ => throw new ArgumentOutOfRangeException(
nameof(options), options.Transport, "Unknown Modbus transport mode."),
};
}
}
@@ -27,6 +27,16 @@
}
</InputSelect>
</div>
<div class="col-md-3">
<label class="form-label">Transport</label>
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<ModbusTransportMode>())
{
<option value="@e">@e</option>
}
</InputSelect>
<div class="form-text">RtuOverTcp = talk raw RTU frames to a serial→Ethernet gateway; must match the gateway's mode.</div>
</div>
<div class="col-md-3">
<label class="form-label">MELSEC sub-family</label>
<InputSelect @bind-Value="_form.MelsecSubFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@@ -290,6 +300,9 @@
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
// Wire transport (Tcp = Modbus/TCP MBAP; RtuOverTcp = RTU framing over a serial→Ethernet gateway)
public ModbusTransportMode Transport { get; set; } = ModbusTransportMode.Tcp;
// Transport flags
public bool AutoReconnect { get; set; } = true;
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
@@ -337,6 +350,7 @@
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Family = o.Family,
MelsecSubFamily = o.MelsecSubFamily,
Transport = o.Transport,
AutoReconnect = o.AutoReconnect,
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
MaxRegistersPerRead = o.MaxRegistersPerRead,
@@ -387,6 +401,7 @@
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
Family = Family,
MelsecSubFamily = MelsecSubFamily,
Transport = Transport,
WriteOnChangeOnly = WriteOnChangeOnly,
AutoReconnect = AutoReconnect,
KeepAlive = new ModbusKeepAliveOptions
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Cluster;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using ZB.MOM.WW.Health.Akka;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
@@ -141,15 +142,39 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
/// </remarks>
/// <param name="members">The current cluster members, in any order.</param>
/// <returns>The oldest Up driver member's address, or <c>null</c> when there is none.</returns>
public static Address? SelectDriverPrimary(IEnumerable<Member> members)
public static Address? SelectDriverPrimary(IEnumerable<Member> members) =>
SelectOldestUpMemberOfRole(members, DriverRole);
/// <summary>
/// Selects the oldest Up member carrying <paramref name="role"/> — the node that
/// <c>ClusterSingletonManager</c> would place a role-scoped singleton on.
/// </summary>
/// <param name="members">The current cluster members, in any order.</param>
/// <param name="role">The cluster role to scope the selection to.</param>
/// <returns>The oldest Up member's address for that role, or <c>null</c> when there is none.</returns>
/// <remarks>
/// <para>
/// The age-ordering rationale in <see cref="SelectDriverPrimary"/> applies verbatim to any
/// role: oldest, never <c>RoleLeader</c>. The rule itself lives in the shared
/// <see cref="ClusterActiveNode"/> (<c>ZB.MOM.WW.Health.Akka</c> 0.3.0) so it has exactly
/// one implementation family-wide — this method is the control plane's entry point into it.
/// </para>
/// <para>
/// Delegating rather than re-implementing is what keeps the redundancy snapshot and the
/// <c>/health/active</c> tier structurally in agreement. They answer for different
/// consumers — the OPC UA <c>ServiceLevel</c> 250/240 split reads this election, while an
/// orchestrator routes admin traffic by the health tier — so a divergence would advertise
/// one node as authoritative while gating the data plane on another. Both apps in the
/// family previously kept private copies of this rule, and both got it wrong in a
/// different way.
/// </para>
/// </remarks>
public static Address? SelectOldestUpMemberOfRole(IEnumerable<Member> members, string role)
{
ArgumentNullException.ThrowIfNull(members);
ArgumentException.ThrowIfNullOrWhiteSpace(role);
return members
.Where(m => m.Status == MemberStatus.Up && m.Roles.Contains(DriverRole))
.OrderBy(m => m, Member.AgeOrdering)
.FirstOrDefault()
?.Address;
return ClusterActiveNode.OldestUpMember(members, role)?.Address;
}
private IReadOnlyList<NodeRedundancyState> BuildSnapshot()
@@ -19,6 +19,14 @@
Google.Protobuf + Grpc.Core.Api flow transitively from Commons (the generated client). -->
<PackageReference Include="Grpc.Net.Client"/>
<PackageReference Include="ZB.MOM.WW.Audit"/>
<!-- For ClusterActiveNode — the shared "oldest Up member of role X" primitive. The control
plane and the /health/active tier must give the SAME answer for which node is in charge,
or the tier reports one node active while the Primary-gated data plane runs on another;
delegating here is what makes that structural rather than a convention. It lives in
Health.Akka because that is the family's Akka-cluster utility package (it already hosts
AkkaClusterStatusPolicy and an endpoint gate); a dedicated cluster package would be a
better home if a third such primitive ever appears. -->
<PackageReference Include="ZB.MOM.WW.Health.Akka"/>
</ItemGroup>
<ItemGroup>
@@ -8,6 +8,7 @@ using ZB.MOM.WW.Health;
using ZB.MOM.WW.Health.Akka;
using ZB.MOM.WW.Health.EntityFrameworkCore;
using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
@@ -17,7 +18,7 @@ public static class HealthEndpoints
{
/// <summary>
/// Registers the shared ZB.MOM.WW health probes. Tier semantics preserved: configdb + akka on
/// ready+active; admin-leader on active only. The configdb probe is admin-only (per-cluster mesh
/// ready+active; cluster-primary on active only. The configdb probe is admin-only (per-cluster mesh
/// Phase 4): a driver-only node holds no ConfigDb, so it is registered iff <paramref name="hasAdmin"/>.
/// </summary>
/// <param name="services">The service collection to register the health checks on.</param>
@@ -53,11 +54,33 @@ public static class HealthEndpoints
failureStatus: null,
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
args: AkkaClusterStatusPolicy.OtOpcUaCompat)
// "Is this node in charge of its mesh?" — Healthy on the one node that owns the active
// work for its Cluster, Unhealthy (503) on its partner, so Traefik pins the AdminUI to the
// node actually hosting the cluster singletons.
//
// Role preference admin-then-driver: a fused central node answers for `admin` (the role
// the singletons and the AdminUI are pinned to), while a driver-only site node answers for
// `driver` — which is exactly RedundancyStateActor.SelectDriverPrimary, the election behind
// IsDriverPrimary and the OPC UA ServiceLevel 250/240 split. Both now route through the
// shared ClusterActiveNode, so the tier and the ServiceLevel cannot disagree.
//
// Scoping is per-mesh for free: after per-cluster mesh Phase 6 a node's ClusterState
// contains only its own application Cluster, so "oldest Up member" is already "oldest Up
// member of this Cluster" — exactly one node answers 200 per Cluster, by construction.
//
// Before Health 0.3.0 this was ActiveNodeHealthCheck(role: "admin"), which answered a
// different question and got it wrong twice: it returned Healthy for any node lacking the
// admin role, so every driver-only site node called itself active, and it selected by
// RoleLeader (lowest address) rather than by age, which is not where Akka places
// singletons. See lmxopcua#494.
.AddTypeActivatedCheck<ActiveNodeHealthCheck>(
"admin-leader",
"cluster-primary",
failureStatus: null,
tags: new[] { ZbHealthTags.Active },
args: "admin")
args: new ActiveNodeHealthCheckOptions
{
RolePreference = new[] { RoleParser.Admin, RoleParser.Driver },
})
// Registered on every node regardless of role (unlike the admin-only configdb probe above):
// the check itself resolves ISyncStatus optionally and reports Healthy when LocalDb is absent
// (admin-only graphs) or replication is default-OFF, so a plain node is never degraded by it.
@@ -21,7 +21,10 @@ COPY profiles/ /fixtures/
# compose profile. See Docker/README.md §exception injection.
COPY exception_injector.py /fixtures/
EXPOSE 5020
# 5020 = the shared port for the standard/dl205/mitsubishi/s7_1500/exception_injection
# profiles (only one binds it at a time); 5021 = the rtu_over_tcp profile, which co-runs
# with standard. Image-metadata honesty only — compose's ports: mapping doesn't need EXPOSE.
EXPOSE 5020 5021
# Default to the standard profile; docker-compose.yml overrides per service.
# --http_port intentionally omitted; pymodbus 3.13's web UI binds on a
@@ -2,15 +2,16 @@
The Modbus driver's integration tests talk to a
[`pymodbus`](https://pymodbus.readthedocs.io/) simulator running as a
pinned Docker container. One image, per-profile service in compose, same
port binding (`5020`) regardless of which profile is live. Docker is the
only supported launch path — a fresh clone needs Docker Desktop and
nothing else.
pinned Docker container. One image, per-profile service in compose. Most
profiles bind `:5020`, so only one of *those* runs at a time; the
`rtu_over_tcp` profile binds `:5021` and is designed to co-run alongside
`standard`. Docker is the only supported launch path — a fresh clone needs
Docker Desktop and nothing else.
| File | Purpose |
|---|---|
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + every profile JSON + `exception_injector.py` |
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection`); all bind `:5020` so only one runs at a time |
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection` bind `:5020`, so only one of those runs at a time; `rtu_over_tcp` binds `:5021` and can run alongside `standard`) |
| [`profiles/*.json`](profiles/) | Same seed-register definitions the native launcher uses — canonical source |
| [`exception_injector.py`](exception_injector.py) | Pure-stdlib Modbus/TCP server that emits arbitrary exception codes per rule — used by the `exception_injection` profile |
@@ -43,10 +44,11 @@ docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 up -d
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down
```
Only one profile binds `:5020` at a time; switch by stopping the current
service + starting another. The integration tests discriminate by a
separate `MODBUS_SIM_PROFILE` env var so they skip correctly when the
wrong profile is live.
Only one of the `:5020` profiles binds at a time; switch by stopping the
current service + starting another. (`rtu_over_tcp` is the exception — it
binds `:5021` and co-runs with `standard`.) The integration tests
discriminate by a separate `MODBUS_SIM_PROFILE` env var so they skip
correctly when the wrong profile is live.
> **⚠️ Profile-cycling gotcha (bit us in the 2026-07 sweep — see
> `archreview/plans/INTEGRATION-SWEEP-STATUS.md`).** Each profile is a
@@ -78,6 +78,30 @@ services:
"--json_file", "/fixtures/s7_1500.json"
]
# RTU-over-TCP profile. Same pymodbus simulator, but the server is framed
# RTU (framer=rtu) instead of socket/MBAP — this is what a serial→Ethernet
# gateway presents. Binds :5021 (NOT the shared :5020) so it co-runs with
# the `standard` profile: two families, two ports, no conflict. Serves
# rtu_over_tcp.json (byte-for-byte standard.json + framer/port swap).
rtu_over_tcp:
profiles: ["rtu_over_tcp"]
image: otopcua-pymodbus:3.13.0
build:
context: .
dockerfile: Dockerfile
container_name: otopcua-pymodbus-rtu_over_tcp
labels:
project: lmxopcua
restart: "no"
ports:
- "5021:5021"
command: [
"pymodbus.simulator",
"--modbus_server", "srv",
"--modbus_device", "dev",
"--json_file", "/fixtures/rtu_over_tcp.json"
]
# Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP
# server shipped as exception_injector.py instead of the pymodbus
# simulator — pymodbus naturally emits only exception codes 02 + 03, and
@@ -0,0 +1,97 @@
{
"_comment": "rtu_over_tcp.json — RTU-over-TCP Modbus server for the integration suite (RTU framing tunnelled over a socket, as a serial→Ethernet gateway presents it). Byte-for-byte standard.json EXCEPT the server block: framer=\"rtu\" (not \"socket\") + port 5021 (not 5020), so it co-runs with the standard profile on :5020. pymodbus 3.13's simulator honors framer=rtu on a TCP server — confirmed on the wire (controller spike, 2026-07-24): raw RTU FC03 read of HR[5] returned `01 03 02 00 05 78 47`, a pure RTU frame with no MBAP header. Layout is identical to standard.json: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history.",
"server_list": {
"srv": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5021,
"framer": "rtu",
"device_id": 1
}
},
"device_list": {
"dev": {
"setup": {
"co size": 2048,
"di size": 2048,
"hr size": 2048,
"ir size": 2048,
"shared blocks": true,
"type exception": false,
"defaults": {
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
}
},
"invalid": [],
"write": [
[0, 31],
[100, 100],
[200, 209],
[1024, 1055],
[1100, 1109]
],
"uint16": [
{"addr": 0, "value": 0}, {"addr": 1, "value": 1},
{"addr": 2, "value": 2}, {"addr": 3, "value": 3},
{"addr": 4, "value": 4}, {"addr": 5, "value": 5},
{"addr": 6, "value": 6}, {"addr": 7, "value": 7},
{"addr": 8, "value": 8}, {"addr": 9, "value": 9},
{"addr": 10, "value": 10}, {"addr": 11, "value": 11},
{"addr": 12, "value": 12}, {"addr": 13, "value": 13},
{"addr": 14, "value": 14}, {"addr": 15, "value": 15},
{"addr": 16, "value": 16}, {"addr": 17, "value": 17},
{"addr": 18, "value": 18}, {"addr": 19, "value": 19},
{"addr": 20, "value": 20}, {"addr": 21, "value": 21},
{"addr": 22, "value": 22}, {"addr": 23, "value": 23},
{"addr": 24, "value": 24}, {"addr": 25, "value": 25},
{"addr": 26, "value": 26}, {"addr": 27, "value": 27},
{"addr": 28, "value": 28}, {"addr": 29, "value": 29},
{"addr": 30, "value": 30}, {"addr": 31, "value": 31},
{"addr": 100, "value": 0,
"action": "increment",
"parameters": {"minval": 0, "maxval": 65535}},
{"addr": 200, "value": 0}, {"addr": 201, "value": 0},
{"addr": 202, "value": 0}, {"addr": 203, "value": 0},
{"addr": 204, "value": 0}, {"addr": 205, "value": 0},
{"addr": 206, "value": 0}, {"addr": 207, "value": 0},
{"addr": 208, "value": 0}, {"addr": 209, "value": 0}
],
"bits": [
{"addr": 1024, "value": 1}, {"addr": 1025, "value": 0},
{"addr": 1026, "value": 1}, {"addr": 1027, "value": 0},
{"addr": 1028, "value": 1}, {"addr": 1029, "value": 0},
{"addr": 1030, "value": 1}, {"addr": 1031, "value": 0},
{"addr": 1032, "value": 1}, {"addr": 1033, "value": 0},
{"addr": 1034, "value": 1}, {"addr": 1035, "value": 0},
{"addr": 1036, "value": 1}, {"addr": 1037, "value": 0},
{"addr": 1038, "value": 1}, {"addr": 1039, "value": 0},
{"addr": 1040, "value": 1}, {"addr": 1041, "value": 0},
{"addr": 1042, "value": 1}, {"addr": 1043, "value": 0},
{"addr": 1044, "value": 1}, {"addr": 1045, "value": 0},
{"addr": 1046, "value": 1}, {"addr": 1047, "value": 0},
{"addr": 1048, "value": 1}, {"addr": 1049, "value": 0},
{"addr": 1050, "value": 1}, {"addr": 1051, "value": 0},
{"addr": 1052, "value": 1}, {"addr": 1053, "value": 0},
{"addr": 1054, "value": 1}, {"addr": 1055, "value": 0},
{"addr": 1100, "value": 0}, {"addr": 1101, "value": 0},
{"addr": 1102, "value": 0}, {"addr": 1103, "value": 0},
{"addr": 1104, "value": 0}, {"addr": 1105, "value": 0},
{"addr": 1106, "value": 0}, {"addr": 1107, "value": 0},
{"addr": 1108, "value": 0}, {"addr": 1109, "value": 0}
],
"uint32": [],
"float32": [],
"string": [],
"repeat": []
}
}
}
@@ -0,0 +1,82 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// Reachability probe for the RTU-over-TCP Modbus simulator (pymodbus in Docker with
/// <c>framer=rtu</c>, see <c>Docker/docker-compose.yml</c> profile <c>rtu_over_tcp</c>) or a
/// real serial→Ethernet Modbus gateway. Mirrors <see cref="ModbusSimulatorFixture"/> but reads
/// <c>MODBUS_RTU_SIM_ENDPOINT</c> (default <c>10.100.0.35:5021</c> — the shared Docker host, a
/// distinct port from the standard profile's <c>:5020</c> so the two fixtures co-run). TCP-connects
/// once at fixture construction; each test checks <see cref="SkipReason"/> and calls
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running simulator
/// still passes `dotnet test` cleanly.
/// </summary>
/// <remarks>
/// Same one-shot-probe discipline as <see cref="ModbusSimulatorFixture"/>: the probe socket is
/// not held for the life of the fixture (tests open their own <see cref="ModbusRtuOverTcpTransport"/>
/// against the same endpoint), and the fixture is a collection fixture so the probe runs once per
/// session rather than per test.
/// </remarks>
public sealed class ModbusRtuOverTcpFixture : IAsyncDisposable
{
// :5021 (not the standard profile's :5020) so the rtu_over_tcp container can co-run with the
// standard container on the shared Docker host. 10.100.0.35 = the shared Docker host (see
// CLAUDE.md "Docker Workflow"). Override with MODBUS_RTU_SIM_ENDPOINT to point at a real
// serial→Ethernet Modbus gateway, or a locally-running container.
private const string DefaultEndpoint = "10.100.0.35:5021";
private const string EndpointEnvVar = "MODBUS_RTU_SIM_ENDPOINT";
/// <summary>Gets the host address of the RTU-over-TCP Modbus simulator.</summary>
public string Host { get; }
/// <summary>Gets the port of the RTU-over-TCP Modbus simulator.</summary>
public int Port { get; }
/// <summary>Gets the skip reason if the simulator is unreachable; otherwise null.</summary>
public string? SkipReason { get; }
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpFixture"/> class.</summary>
public ModbusRtuOverTcpFixture()
{
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
var parts = raw.Split(':', 2);
Host = parts[0];
Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 502;
try
{
// Force IPv4 on the probe — pymodbus binds 0.0.0.0 (IPv4 only) while .NET default-resolves
// "localhost" → IPv6 ::1 first and only then tries IPv4, surfacing as a 2s timeout under
// .NET 10. Resolving + dialing explicit IPv4 sidesteps the dual-stack ordering. (Same
// rationale as ModbusSimulatorFixture.)
using var client = new TcpClient(System.Net.Sockets.AddressFamily.InterNetwork);
var task = client.ConnectAsync(
System.Net.Dns.GetHostAddresses(Host)
.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
?? System.Net.IPAddress.Loopback,
Port);
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
SkipReason = $"RTU-over-TCP Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile rtu_over_tcp up -d --build) " +
$"or override {EndpointEnvVar}, then re-run.";
}
}
catch (Exception ex)
{
SkipReason = $"RTU-over-TCP Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile rtu_over_tcp up -d --build) " +
$"or override {EndpointEnvVar}, then re-run.";
}
}
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Xunit.CollectionDefinition(Name)]
public sealed class ModbusRtuOverTcpCollection : Xunit.ICollectionFixture<ModbusRtuOverTcpFixture>
{
public const string Name = "ModbusRtuOverTcp";
}
@@ -0,0 +1,94 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// End-to-end round-trip against the <c>rtu_over_tcp.json</c> pymodbus profile (or a real
/// serial→Ethernet Modbus gateway when <c>MODBUS_RTU_SIM_ENDPOINT</c> points at one), driving the
/// full <see cref="ModbusDriver"/> + real <see cref="ModbusRtuOverTcpTransport"/> stack with
/// <see cref="ModbusTransportMode.RtuOverTcp"/>. Proves the driver reads a seeded holding register
/// and writes+reads-back a scratch register when the wire carries RTU framing
/// (<c>[addr][PDU][CRC-16]</c>, no MBAP header) rather than Modbus/TCP.
/// </summary>
/// <remarks>
/// pymodbus 3.13's simulator honors <c>framer=rtu</c> on a TCP server — confirmed on the wire
/// (controller spike, 2026-07-24): a raw RTU FC03 read of HR[5] returned
/// <c>01 03 02 00 05 78 47</c>, a pure RTU frame with no MBAP header (data 0x0005 = 5). So the
/// fixture is the plain pymodbus simulator with framer/port swapped — no stdlib fallback server.
/// </remarks>
[Collection(ModbusRtuOverTcpCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "RtuOverTcp")]
public sealed class ModbusRtuOverTcpTests(ModbusRtuOverTcpFixture sim)
{
/// <summary>Reads a seeded holding register (HR[5]=5) over RTU-over-TCP framing.</summary>
[Fact]
public async Task Reads_a_seeded_holding_register_over_rtu_framing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var opts = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Transport = ModbusTransportMode.RtuOverTcp,
Timeout = TimeSpan.FromSeconds(2),
Probe = new ModbusProbeOptions { Enabled = false },
RawTags = ModbusRawTags.Entries([
new ModbusTagDefinition(
Name: "HR5",
Region: ModbusRegion.HoldingRegisters,
Address: 5,
DataType: ModbusDataType.UInt16,
Writable: false),
]),
};
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-rtu-int");
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(0u); // Good
results[0].Value.ShouldBe((ushort)5); // HR[5] seeded = address-as-value
}
/// <summary>Writes then reads back a scratch holding register (HR[200]) over RTU-over-TCP framing.</summary>
[Fact]
public async Task Writes_and_reads_back_a_holding_register_over_rtu_framing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var opts = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Transport = ModbusTransportMode.RtuOverTcp,
Timeout = TimeSpan.FromSeconds(2),
Probe = new ModbusProbeOptions { Enabled = false },
RawTags = ModbusRawTags.Entries([
new ModbusTagDefinition(
Name: "HR200",
Region: ModbusRegion.HoldingRegisters,
Address: 200,
DataType: ModbusDataType.UInt16,
Writable: true),
]),
};
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-rtu-int-w");
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
var writes = await driver.WriteAsync(
[new WriteRequest("HR200", (ushort)4242)],
TestContext.Current.CancellationToken);
writes.Count.ShouldBe(1);
writes[0].StatusCode.ShouldBe(0u);
var back = await driver.ReadAsync(["HR200"], TestContext.Current.CancellationToken);
back.Count.ShouldBe(1);
back[0].StatusCode.ShouldBe(0u);
back[0].Value.ShouldBe((ushort)4242);
}
}
@@ -0,0 +1,27 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusCrcTests
{
// Known CRC-16/MODBUS vectors (init 0xFFFF, poly 0xA001). CRC returned as ushort;
// on the wire it is appended low-byte-first.
[Theory]
// FC03 read HR: unit 1, addr 0, qty 1 -> CRC 0x0A84 (bytes 84 0A on the wire)
[InlineData(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 }, (ushort)0x0A84)]
// "123456789" canonical check value for CRC-16/MODBUS = 0x4B37
[InlineData(new byte[] { 0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39 }, (ushort)0x4B37)]
public void Compute_matches_known_vectors(byte[] frame, ushort expected)
=> ModbusCrc.Compute(frame).ShouldBe(expected);
[Fact]
public void AppendLowByteFirst_writes_lo_then_hi()
{
var body = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
var withCrc = ModbusCrc.Append(body);
withCrc.Length.ShouldBe(body.Length + 2);
withCrc[^2].ShouldBe((byte)0x84); // CRC low byte
withCrc[^1].ShouldBe((byte)0x0A); // CRC high byte
}
}
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// (2) Sub-second <see cref="TimeSpan"/> values on <c>ModbusKeepAliveOptions.Time</c> /
/// <c>Interval</c> — the int-cast in <c>EnableKeepAlive</c> truncated <c>500&#160;ms</c> to
/// <c>0</c>, which most OSes interpret as "use the default", silently defeating the
/// configured timing. <c>ModbusTcpTransport.ClampToWholeSeconds</c> rounds up to a minimum
/// configured timing. <c>ModbusSocketLifecycle.ClampToWholeSeconds</c> rounds up to a minimum
/// of 1&#160;second.
/// </summary>
[Trait("Category", "Unit")]
@@ -70,7 +70,7 @@ public sealed class ModbusEdgeCaseValidationTests
[InlineData(60_000, 60)]
public void ClampToWholeSeconds_rounds_up_to_at_least_one_second(int ms, int expected)
{
ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected);
ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected);
}
/// <summary>Verifies that negative time spans are treated as one second.</summary>
@@ -80,6 +80,6 @@ public sealed class ModbusEdgeCaseValidationTests
// Defensive — operators occasionally configure a negative TimeSpan thinking it disables
// the feature. The OS would reject the negative int — clamping to 1 keeps the socket
// valid until the operator fixes the config.
ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1);
ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1);
}
}
@@ -0,0 +1,134 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusRtuFramingTests
{
private static MemoryStream Canned(params byte[] frame) => new(frame);
[Fact]
public void BuildAdu_prefixes_unit_and_appends_crc()
{
var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 });
adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
}
[Fact]
public async Task ReadResponse_FC03_returns_bare_pdu()
{
// addr=01 fc=03 byteCount=02 data=00 0A + CRC(lo,hi)
var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A };
var frame = ModbusCrc.Append(body);
await using var s = Canned(frame);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
}
[Fact]
public async Task ReadResponse_exception_frame_throws_ModbusException()
{
// addr=01 fc=0x83 exc=0x02 + CRC
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
await using var s = Canned(frame);
var ex = await Should.ThrowAsync<ModbusException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.FunctionCode.ShouldBe((byte)0x03);
ex.ExceptionCode.ShouldBe((byte)0x02);
}
[Fact]
public async Task ReadResponse_bad_crc_throws_desync()
{
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
frame[^1] ^= 0xFF; // corrupt CRC high byte
await using var s = Canned(frame);
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.CrcMismatch);
}
[Fact]
public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu()
{
// addr=01 fc=06 addr=00 07 value=00 2A + CRC -> PDU = 06 00 07 00 2A
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A });
await using var s = Canned(frame);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A });
}
[Fact]
public async Task ReadResponse_wrong_unit_address_throws_desync()
{
// CRC-valid FC03 frame addressed to unit 0x02, but we asked for 0x01 -> unit-mismatch desync.
var frame = ModbusCrc.Append(new byte[] { 0x02, 0x03, 0x02, 0x00, 0x0A });
await using var s = Canned(frame);
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.UnitMismatch);
}
[Fact]
public async Task ReadResponse_FC03_deframes_correctly_when_delivered_in_dribbles()
{
// Same valid FC03 frame, but the stream hands back only 1-2 bytes per ReadAsync — proves
// the ReadExactlyAsync accumulation loop reassembles a length-less frame across many reads.
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
await using var s = new DribbleStream(frame, chunk: 2);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
}
[Fact]
public async Task ReadResponse_stream_ends_early_throws_desync()
{
// Only the first 4 bytes of a 7-byte FC03 frame are available, then EOF (ReadAsync returns 0)
// -> the truncation path in ReadExactlyAsync must surface a desync, not hang or mis-parse.
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
var truncated = frame[..4];
await using var s = new DribbleStream(truncated, chunk: 2);
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
}
/// <summary>
/// A read-only test double that dribbles its backing bytes out at most <c>chunk</c> at a time
/// per <see cref="ReadAsync(Memory{byte}, CancellationToken)"/> call, then returns 0 (EOF)
/// once exhausted. Exercises the partial-read accumulation loop and the truncation → desync
/// path that a single-shot <see cref="MemoryStream"/> never reaches.
/// </summary>
private sealed class DribbleStream(byte[] data, int chunk) : Stream
{
private int _pos;
public override int Read(byte[] buffer, int offset, int count)
{
var n = Math.Min(Math.Min(chunk, count), data.Length - _pos);
if (n <= 0) return 0;
Array.Copy(data, _pos, buffer, offset, n);
_pos += n;
return n;
}
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var n = Math.Min(Math.Min(chunk, buffer.Length), data.Length - _pos);
if (n <= 0) return ValueTask.FromResult(0);
data.AsSpan(_pos, n).CopyTo(buffer.Span);
_pos += n;
return ValueTask.FromResult(n);
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => data.Length;
public override long Position { get => _pos; set => throw new NotSupportedException(); }
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}
@@ -0,0 +1,110 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Exercises <see cref="ModbusRtuOverTcpTransport"/>'s send/receive orchestration, single-flight,
/// and per-op deadline against an in-memory duplex fake injected through the <c>ForTest</c> seam —
/// no real socket. The fake records the request ADU the transport writes and replays a canned RTU
/// response on read, or blocks forever (honouring cancellation) when <c>stall</c> is set.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusRtuOverTcpTransportTests
{
[Fact]
public async Task SendAsync_writes_rtu_adu_and_returns_deframed_pdu()
{
// Response the fake gateway will emit: FC03, 1 reg = 0x000A.
var response = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
var fake = new CapturingDuplexStream(response);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
var pdu = await transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
// The request went out as an RTU ADU: unit + pdu + CRC, NO MBAP header.
fake.Written.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
}
[Fact]
public async Task SendAsync_exception_pdu_surfaces_ModbusException()
{
var response = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
var fake = new CapturingDuplexStream(response);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
await Should.ThrowAsync<ModbusException>(
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken));
}
[Fact]
public async Task SendAsync_stalled_gateway_hits_per_op_deadline()
{
var fake = new CapturingDuplexStream(respondBytes: Array.Empty<byte>(), stall: true);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200));
await Should.ThrowAsync<ModbusTransportDesyncException>(
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken));
}
/// <summary>
/// In-memory duplex <see cref="Stream"/> test double: records everything written and replays
/// <c>respondBytes</c> on read. When <c>stall</c> is set the read blocks until its cancellation
/// token fires (simulating a frozen gateway) so the transport's per-op deadline is exercised.
/// </summary>
private sealed class CapturingDuplexStream : Stream
{
private readonly byte[] _response;
private readonly bool _stall;
private readonly MemoryStream _written = new();
private int _readPos;
public CapturingDuplexStream(byte[] respondBytes, bool stall = false)
{
_response = respondBytes;
_stall = stall;
}
/// <summary>Gets the bytes the transport wrote to this stream (the request ADU).</summary>
public byte[] Written => _written.ToArray();
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
{
if (_stall)
{
// Frozen gateway: never answer. Block until the per-op deadline (or caller) cancels.
var tcs = new TaskCompletionSource();
await using (ct.Register(() => tcs.TrySetCanceled(ct)))
await tcs.Task.ConfigureAwait(false);
return 0; // unreachable — the await above always throws when cancelled
}
var remaining = _response.Length - _readPos;
if (remaining <= 0) return 0;
var n = Math.Min(remaining, buffer.Length);
_response.AsSpan(_readPos, n).CopyTo(buffer.Span);
_readPos += n;
return n;
}
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default)
{
await _written.WriteAsync(buffer, ct).ConfigureAwait(false);
}
public override Task FlushAsync(CancellationToken ct) => Task.CompletedTask;
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
}
}
@@ -0,0 +1,33 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Pins the socket-lifecycle surface extracted from <see cref="ModbusTcpTransport"/> into the
/// reusable <see cref="ModbusSocketLifecycle"/> (Task 3). The clamp helper moved with it, and a
/// connect to a dead port must still surface the underlying socket failure.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusSocketLifecycleTests
{
/// <summary>Verifies the whole-seconds clamp rounds sub-second/negative durations up to a minimum of one.</summary>
/// <param name="seconds">The input duration in seconds.</param>
/// <param name="expected">The expected clamped value in whole seconds.</param>
[Theory]
[InlineData(0.4, 1)] // sub-second rounds up to 1 (the int-cast truncation guard)
[InlineData(2.0, 2)]
[InlineData(-5.0, 1)] // negative clamps to 1
public void ClampToWholeSeconds_rounds_up_min_one(double seconds, int expected)
=> ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
/// <summary>Verifies a connect to a dead port surfaces the underlying socket failure.</summary>
[Fact]
public async Task Connect_to_dead_port_surfaces_socket_failure()
{
var life = new ModbusSocketLifecycle("127.0.0.1", 1, TimeSpan.FromMilliseconds(300),
autoReconnect: false);
await Should.ThrowAsync<System.Net.Sockets.SocketException>(
life.ConnectAsync(TestContext.Current.CancellationToken));
}
}
@@ -0,0 +1,47 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportConfigRoundTripTests
{
// Mirrors the AdminUI ModbusDriverForm serializer: camelCase + string enums.
private static readonly JsonSerializerOptions _adminOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
[Fact]
public void Transport_serializes_as_a_string_not_a_number()
{
var opts = new ModbusDriverOptions { Host = "10.20.0.50", Port = 4001, Transport = ModbusTransportMode.RtuOverTcp };
var json = JsonSerializer.Serialize(opts, _adminOpts);
json.ShouldContain("\"transport\":\"RtuOverTcp\"");
json.ShouldNotContain("\"transport\":1", Case.Sensitive);
}
[Fact]
public void Factory_binds_RtuOverTcp_from_string_config()
{
const string json = """{ "host": "10.20.0.50", "port": 4001, "transport": "RtuOverTcp" }""";
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-rtu", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
}
[Fact]
public void Factory_defaults_Transport_to_Tcp_when_omitted()
{
const string json = """{ "host": "10.0.0.10" }""";
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-tcp", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Transport.ShouldBe(ModbusTransportMode.Tcp);
}
}
@@ -0,0 +1,27 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportFactoryTests
{
[Fact]
public void Tcp_mode_builds_tcp_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.Tcp })
.ShouldBeOfType<ModbusTcpTransport>();
[Fact]
public void RtuOverTcp_mode_builds_rtu_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
[Fact]
public void Default_options_build_tcp_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions())
.ShouldBeOfType<ModbusTcpTransport>();
[Fact]
public void Unknown_transport_mode_throws()
=> Should.Throw<ArgumentOutOfRangeException>(
() => ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = (ModbusTransportMode)999 }));
}
@@ -0,0 +1,15 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportModeTests
{
[Fact]
public void Tcp_is_the_default_zero_member()
=> ((ModbusTransportMode)0).ShouldBe(ModbusTransportMode.Tcp);
[Fact]
public void Exactly_two_members_ship_in_v1()
=> Enum.GetNames<ModbusTransportMode>().ShouldBe(["Tcp", "RtuOverTcp"]);
}
@@ -0,0 +1,33 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Confirms the driver's default transport-factory closure — and, by extension, the
/// probe's transport construction — routes through <see cref="ModbusTransportFactory"/>
/// instead of hardcoding <see cref="ModbusTcpTransport"/>, so an <c>RtuOverTcp</c>-authored
/// config actually gets RTU framing rather than silently running as TCP/MBAP.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusTransportWiringTests
{
private static IModbusTransport BuildDefaultTransport(ModbusDriverOptions opts)
{
using var driver = new ModbusDriver(opts, "wire-test");
var factory = (Func<ModbusDriverOptions, IModbusTransport>)typeof(ModbusDriver)
.GetField("_transportFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
return factory(opts);
}
[Fact]
public void Default_closure_builds_rtu_transport_for_RtuOverTcp()
=> BuildDefaultTransport(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
[Fact]
public void Default_closure_builds_tcp_transport_for_Tcp()
=> BuildDefaultTransport(new ModbusDriverOptions())
.ShouldBeOfType<ModbusTcpTransport>();
}
@@ -61,4 +61,15 @@ public sealed class ModbusDriverFormModelTests
json.ShouldContain("\"family\":\"MELSEC\""); // enum-as-name (not a number), camelCase key
json.ShouldNotContain("\"family\":0", Case.Sensitive); // never the numeric enum form
}
[Fact]
public void Round_trip_preserves_Transport_mode()
{
var form = new ModbusDriverForm.FormModel { Transport = ModbusTransportMode.RtuOverTcp };
var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
var back = ModbusDriverForm.FormModel.FromOptions(
JsonSerializer.Deserialize<ModbusDriverOptions>(json, JsonOpts)!);
back.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
json.ShouldContain("\"transport\":\"RtuOverTcp\""); // name string, never a number
}
}
@@ -122,6 +122,34 @@ public sealed class RedundancyPrimaryElectionTests : IAsyncLifetime
"the fixture is only meaningful if the lowest-addressed node is the YOUNGER one");
}
/// <summary>
/// The generalised selector agrees with the driver-specific one, and is scoped to the role it is
/// asked about.
/// </summary>
/// <remarks>
/// <c>/health/active</c> now answers from <c>SelectOldestUpMemberOfRole</c> while the redundancy
/// snapshot answers from <c>SelectDriverPrimary</c>. If those two ever disagreed, the tier would
/// report a different node as active than the one whose OPC UA ServiceLevel reads 250 — so the
/// parity is the property worth pinning, not either answer alone.
/// </remarks>
[Fact]
public void OldestUpMemberOfRole_agrees_with_SelectDriverPrimary_and_is_role_scoped()
{
var members = Akka.Cluster.Cluster.Get(_oldest!).State.Members;
RedundancyStateActor.SelectOldestUpMemberOfRole(members, "driver")
.ShouldBe(RedundancyStateActor.SelectDriverPrimary(members));
// Both nodes here carry admin AND driver, so the admin-scoped answer is the same oldest node
// — and, critically, still the oldest rather than the lowest-addressed role leader.
RedundancyStateActor.SelectOldestUpMemberOfRole(members, "admin")!.Port
.ShouldBe(OldestPort);
// A role nobody carries has no owner. The health check maps this to Unhealthy: during a cold
// start nobody is active, and nobody may claim to be.
RedundancyStateActor.SelectOldestUpMemberOfRole(members, "no-such-role").ShouldBeNull();
}
/// <summary>
/// The elected Primary is the oldest driver member — the node that hosts the cluster singletons —
/// not the lowest-addressed one.
@@ -0,0 +1,59 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Health;
using ZB.MOM.WW.Health.Akka;
using ZB.MOM.WW.OtOpcUa.Host.Health;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Health;
/// <summary>
/// Pins OtOpcUa's active-tier wiring. The <i>rule</i> (oldest Up member of a role) now lives in the
/// shared <c>ClusterActiveNode</c> and is tested there against a real two-node cluster; what stays
/// OtOpcUa's own decision — and what regressed in <c>lmxopcua#494</c> — is <b>which check is
/// registered on the active tag, and with which roles</b>.
/// </summary>
public sealed class ActiveTierRegistrationTests
{
private static HealthCheckRegistration ClusterPrimaryRegistration(bool hasAdmin) =>
new ServiceCollection()
.AddOtOpcUaHealth(hasAdmin)
.BuildServiceProvider()
.GetRequiredService<IOptions<HealthCheckServiceOptions>>()
.Value.Registrations
.Where(r => r.Name == "cluster-primary")
.ShouldHaveSingleItem();
[Fact]
public void Active_tier_uses_the_shared_ActiveNodeHealthCheck()
{
// Not a bespoke copy. Two apps in the family independently hand-rolled this check because the
// shared one selected by RoleLeader; since Health 0.3.0 the shared one is the correct rule, so
// a private reimplementation reappearing here would be the regression.
var registration = ClusterPrimaryRegistration(hasAdmin: true);
registration.Factory(new ServiceCollection().BuildServiceProvider())
.ShouldBeOfType<ActiveNodeHealthCheck>();
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Active_tier_check_is_registered_on_every_node_role(bool hasAdmin)
{
// Registered on driver-only nodes too, unlike the admin-only configdb probe. A site pair needs
// an active tier just as much as the central pair does — under the old admin-scoped check all
// four driver-only site nodes answered 200 and no consumer could find a site's Primary.
ClusterPrimaryRegistration(hasAdmin).Tags.ShouldContain(ZbHealthTags.Active);
}
[Fact]
public void Active_tier_check_is_not_on_the_ready_tier()
{
// Readiness must not depend on being the active node: a healthy standby is ready to serve, and
// tagging it ready would take the whole pair out of rotation whenever one node is standby.
ClusterPrimaryRegistration(hasAdmin: true).Tags.ShouldNotContain(ZbHealthTags.Ready);
}
}