diff --git a/docs/plans/2026-07-15-driver-expansion-program-design.md b/docs/plans/2026-07-15-driver-expansion-program-design.md index afff3f64..cc80242f 100644 --- a/docs/plans/2026-07-15-driver-expansion-program-design.md +++ b/docs/plans/2026-07-15-driver-expansion-program-design.md @@ -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 0–2):** [`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) · diff --git a/docs/plans/2026-07-24-driver-expansion-tracking.md b/docs/plans/2026-07-24-driver-expansion-tracking.md new file mode 100644 index 00000000..3975bb5b --- /dev/null +++ b/docs/plans/2026-07-24-driver-expansion-tracking.md @@ -0,0 +1,129 @@ +# Driver-expansion — Waves 0–2 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** | S–M | 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) | 📝 **Plan ready** (11 tasks) | **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) | S–M | **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) | S–M | 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) | M–L | 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. + +--- + +## 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 — 📝 Plan ready +- **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). +- **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:** add an `rtu_over_tcp` profile to the existing Modbus integration fixture. First P1 + step is confirming the pymodbus simulator exposes the RTU framer on a TCP server. +- **Effort:** **S — the lowest on the roadmap.** Recommended first. + +### 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:** S–M. + +--- + +## 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:** S–M (≈1–1.5 wk with TrakHound, ≈2.5–3 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 0–14, a complete shippable milestone; P2 Sparkplug B = Tasks 15–26). 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:** M–L. **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** via subagent-driven development — Modbus RTU first (lowest effort, no new infra), + then SQL poll. Both plans are 📝 ready; execute with + `/superpowers-extended-cc:executing-plans docs/plans/2026-07-24-modbus-rtu-driver.md`. +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. diff --git a/docs/plans/2026-07-24-modbus-rtu-driver.md b/docs/plans/2026-07-24-modbus-rtu-driver.md new file mode 100644 index 00000000..e89b908f --- /dev/null +++ b/docs/plans/2026-07-24-modbus-rtu-driver.md @@ -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` — 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().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; + +/// +/// Wire transport for a Modbus driver instance. = Modbus/TCP (MBAP + TxId); +/// = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to +/// a serial→Ethernet gateway. Default — existing configs omit the field. +/// A direct-serial Rtu member is intentionally NOT reserved here (descoped 2026-07-15); +/// do not number-squat it. +/// +public enum ModbusTransportMode +{ + /// Modbus/TCP — 7-byte MBAP header + transaction id (the historical default). + Tcp, + + /// RTU framing ([addr][PDU][CRC-16]) tunnelled over a socket to a serial→Ethernet gateway. + 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)` (init `0xFFFF`, xor byte, 8× shift-right, xor `0xA001` on carry) and `static byte[] Append(ReadOnlySpan 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( + 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( + 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 pdu)` = `ModbusCrc.Append([unitId, ..pdu])`; `static async Task 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( + 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( + 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(), stall: true); + await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200)); + await Should.ThrowAsync( + 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(); + + [Fact] + public void RtuOverTcp_mode_builds_rtu_transport() + => ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp }) + .ShouldBeOfType(); + + [Fact] + public void Default_options_build_tcp_transport() + => ModbusTransportFactory.Create(new ModbusDriverOptions()) + .ShouldBeOfType(); +} +``` +(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(...)` 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(dto.Transport, "", 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)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(); + + [Fact] + public void Default_closure_builds_tcp_transport_for_Tcp() + => BuildDefaultTransport(new ModbusDriverOptions()) + .ShouldBeOfType(); +} +``` +(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` `` 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(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 `` looping `Enum.GetValues()` 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="` + — 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 0–2) → transport (Tasks 3–4) → factory + config + driver/probe wiring (Tasks 5–7) → 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. diff --git a/docs/plans/2026-07-24-modbus-rtu-driver.md.tasks.json b/docs/plans/2026-07-24-modbus-rtu-driver.md.tasks.json new file mode 100644 index 00000000..dc74d3de --- /dev/null +++ b/docs/plans/2026-07-24-modbus-rtu-driver.md.tasks.json @@ -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" +} diff --git a/docs/plans/2026-07-24-mqtt-sparkplug-driver.md b/docs/plans/2026-07-24-mqtt-sparkplug-driver.md new file mode 100644 index 00000000..26ce6e80 --- /dev/null +++ b/docs/plans/2026-07-24-mqtt-sparkplug-driver.md @@ -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 0–14 (a complete shippable milestone); P2 (Sparkplug B ingest) = Tasks 15–26 (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 ``) +- 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 `` (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(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(() => 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().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`; 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()); + 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` (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());` 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(); +} + +[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 5–30 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();` alongside the existing two near line 75–76) +- 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=" +``` +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`); `` — 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` 0–255 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 16–20 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(); + 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(); 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` (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. diff --git a/docs/plans/2026-07-24-mqtt-sparkplug-driver.md.tasks.json b/docs/plans/2026-07-24-mqtt-sparkplug-driver.md.tasks.json new file mode 100644 index 00000000..2abe8b56 --- /dev/null +++ b/docs/plans/2026-07-24-mqtt-sparkplug-driver.md.tasks.json @@ -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" +} diff --git a/docs/plans/2026-07-24-mtconnect-driver.md b/docs/plans/2026-07-24-mtconnect-driver.md new file mode 100644 index 00000000..4118028b --- /dev/null +++ b/docs/plans/2026-07-24-mtconnect-driver.md @@ -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` 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` (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 ` + `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 (~300–500 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 `` 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` (`PreserveNewest…`). +- Add all three to `ZB.MOM.WW.OtOpcUa.slnx` beside the Modbus entries (lines ~29–92 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 ProbeAsync(CancellationToken ct); + Task CurrentAsync(CancellationToken ct); + IAsyncEnumerable 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` (`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(""); + 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(""); + 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(""); + 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? agentClientFactory = null, ILogger? 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[] { "", "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(); + d.OnDataChange += (_, e) => seen.Add(e.FullReference); + await d.SubscribeAsync(new[] { "" }, TimeSpan.FromMilliseconds(500), default); + seen.ShouldContain(""); +} + +[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[] { "" }, 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: , IsArray: rep==TIME_SERIES, ArrayDim: , 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(""); + 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[] { "" }, 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())`. Factory `JsonOptions` mirror Modbus (`PropertyNameCaseInsensitive`, `ReadCommentHandling=Skip`, `AllowTrailingCommas`, **no enum converter** — enum-carrying DTO fields stay `string?` + go through `ParseEnum`). + +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(() => 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());` 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//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/ --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 `