Task 26 — the P2 milestone gate. Ran the live /run verification on an isolated
docker-dev rig (project otopcua-mqtt, ports 9210/4850-4851/14350, image built
from this branch) against the real Mosquitto TLS+auth broker and the C#
Sparkplug edge-node simulator on 10.100.0.35, and recorded the result.
The gate found three defects. Two are the same defect class the P1 gate found
twice — a hand-maintained AdminUI surface left behind by a driver-side feature —
and the third is a pre-existing cross-driver bug that only a Sparkplug flow
could surface.
1. CRITICAL, FIXED — MqttDriverForm still shipped its P1 Sparkplug PLACEHOLDER.
Switching Mode to SparkplugB rendered a "not available yet" notice and NO
Group ID field, so with Sparkplug ingest fully shipped there was still no
way to author a Sparkplug driver from the AdminUI at all. Sparkplug.GroupId
is the driver's entire subscription filter (spBv1.0/{GroupId}/#): blank ⇒
connected, Healthy, ingesting nothing. Now authors all five Sparkplug keys,
MERGES over the existing sub-object rather than replacing it (so a key a
newer driver adds inside it survives an older AdminUI), leaves it untouched
in Plain mode, and validates the group id as the topic segment it is.
Pinned by 8 MqttDriverFormModelTests cases, verified falsifiable — 8 RED
with the fix stubbed out.
2. PRE-EXISTING + CROSS-DRIVER, FIXED — every node label in the shared
DriverBrowseTree was an <a href="#">. In a Blazor Web App, blazor.web.js's
enhanced-navigation click interceptor resolves a bare "#" against
<base href="/">, so clicking ANY browse-tree node label navigated the whole
AdminUI to "/", tore down the circuit, and destroyed the hosting modal —
losing the browse session AND the tag selection. @onclick:preventDefault
does not help: it suppresses the browser's default action, not Blazor's own
interceptor. Labels are now <button type="button">. Dates to the component's
introduction (2026-05-28) and affects EVERY driver's picker; it surfaced
only now because choosing a rebirth scope is the first flow that requires
clicking a label rather than the ▶ toggle (always a button, always fine).
3. FIXED (mitigation) — the browse tree rendered exactly once, at open, and
never again, while OpenAsync returns as soon as the SUBSCRIBE is granted.
On Sparkplug that means it is almost always empty forever, because births
are never retained. Added a Refresh button that re-reads the root against
the SAME session (nothing reconnects, nothing observed is lost, the tag
selection is kept, only the armed rebirth scope is cleared).
Live gate result (all against the real broker + simulator, group OtOpcUaSim):
- driver authored end-to-end through the fixed MqttDriverForm; blob is
Mode:SparkplugB + Sparkplug.GroupId:OtOpcUaSim with enums as names
- browse: BROWSER OPEN chip, Sparkplug-only rebirth panel, tree renders
Group → EdgeNode → [Device] → Metric with the device folder Filler1 and
node-level metric leaves SIDE BY SIDE, and "Node Control/Rebirth" as ONE
leaf (the metric-name-is-not-a-topic-segment rule holding)
- rebirth: group scope names the group + the 32-node cap, Cancel published
NOTHING (simulator log unchanged); edge-node scope → "1 command published"
and the simulator logged "rebirth NCMD #1 received; republishing metadata";
group scope → "2 commands published", both edge nodes re-birthed; a device
and a metric both resolve UP to "edge node EdgeA"; the panel does NOT render
for a Plain MQTT device
- browse-commit wrote bindable tuples with no topic/address key —
{"groupId","edgeNodeId","metricName","deviceId"} for the device metric and
deviceId ABSENT (not blank) for the node-level one, datatypes inherited from
the birth (Int64 / Float)
- deployed (Sealed), and both nodes served live changing values through OPC UA
at the 2 s cadence — Good 0x00000000, no BadNodeIdUnknown
- death→STALE: stopping the simulator drove both nodes to 0x80000000 with an
empty value ~2 s later, and restarting it recovered them to Good on the new
birth (FillCount back to its birth-declared 1000, then counting)
- rediscovery fired exactly TWICE (once per authored scope: OtOpcUaSim/EdgeA
7 metrics, OtOpcUaSim/EdgeA/Filler1 3 metrics) and was then gated across
many subsequent births/rebirths — the anti-storm change gate working, and
EdgeB correctly filtered out as an unauthored scope
- tag editor: opens in SparkplugB (mode inference on reopen) with all four
descriptor fields populated; blank group → "A Sparkplug group ID is
required."; "Plant/1" rejected; "Node Control/Rebirth" ACCEPTED and saved
with the slash whole; dataType override persists and its key is ABSENT again
after selecting "(from birth certificate)"
Two things are NOT verified, and are recorded rather than claimed:
- Rediscovery is INERT in v3 and this is a platform gap, not an MQTT one:
nothing subscribes to IRediscoverable.OnRediscoveryNeeded and
DriverHostActor.HandleDiscoveredNodes hard-returns. A DBIRTH introducing a
metric does NOT change the OPC UA tree; redeploy. Verified from the driver's
log line only, exactly as far as it can be verified.
- A metric name containing "/" cannot be BROWSE-COMMITTED: the derived raw tag
Name is a RawPath segment and may not contain "/", so the spec-mandatory
"Node Control/Rebirth" is refused ("Row 3: Name must not contain '/'"). The
refusal is loud and all-or-nothing, never a silently mis-bound tag, and
Manual entry is the working path. Fixing it needs a name-sanitisation policy
with a collision answer, so it was recorded rather than guessed at.
Also left open + documented: an empty browse tree cannot arm a rebirth (the
scope comes from a tree click, and the session side already accepts a bare
{group}/{edgeNode} — only a UI path is missing); _canRebirth is captured at
browse-open; the tag editor injects the Plain-only payloadFormat key into a
Sparkplug blob (cosmetic — the factory routes on the tuple).
Suites: offline 1528 passed / 0 failed (581 Driver.Mqtt + 809 AdminUI + 138
Core.Abstractions); live 15/15 against the broker + simulator.
Docs: docs/drivers/Mqtt.md brought fully up to date for P2 (Sparkplug config
sub-object, both tag shapes, the ingest state machine, rediscovery, browse +
rebirth + Refresh, a Known-gaps table); Mqtt-Test-Fixture.md gains the simulator
and drops its "Sparkplug has no fixture" claims; infra/README.md §3 gains the
simulator row; CLAUDE.md gains the simulator endpoint facts; the tracking doc
marks MQTT/Sparkplug COMPLETE. docker-dev/docker-compose.mqtt.yml is the
isolated-rig overlay the gate ran on.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
17 KiB
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.mdLast 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 | plan · tasks | 🟡 Live gate open | S–M | none (retrofits shipped drivers) |
| 1 | Modbus RTU (extend Modbus) | design | plan · tasks | 📝 Plan ready (11 tasks) | S (lowest) | pymodbus rtu_over_tcp — CI-simulatable, no new infra |
| 1 | SQL poll | design | plan · tasks | 📝 Plan ready (22 tasks) | S–M | existing central SQL Server 10.100.0.35,14330 + SQLite unit |
| 2 | MTConnect Agent | design | plan · tasks | 📝 Plan ready (23 tasks) | S–M | Dockerized mtconnect/cppagent — CI-simulatable |
| 2 | MQTT / Sparkplug B | design | plan · tasks | ✅ COMPLETE — P1 + P2 shipped, both live-gated (Tasks 0–26) | M–L | Mosquitto TLS+auth + C# Sparkplug edge-node simulator, live at 10.100.0.35:8883 |
Waves 3+ (BACnet/IP, Omron) and the deferred MELSEC are tracked in the program design doc §6/§8, not here. Omron CIP is the program's sole real-hardware wire gate; BACnet's broadcast/BBMD leg is env-gated live. Both are out of this file's scope until they're pulled forward.
Executing a plan (git worktree + subagent-per-task)
Each 📝 plan is executed with the subagent-driven-development skill: it sets up an isolated
git worktree first (via using-git-worktrees), then dispatches a fresh subagent per task,
running the classification-driven review chain (trivial = implement only … high-risk =
spec-review → code-review → integration review) between tasks. The .tasks.json next to each plan
tracks progress and lets a later session resume.
Paste one of these into Claude Code to build a driver:
| Deliverable | Command |
|---|---|
| Modbus RTU (Wave 1) | Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-modbus-rtu-driver.md in a new git worktree |
| SQL poll (Wave 1) | Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-sql-poll-driver.md in a new git worktree |
| MTConnect (Wave 2) | Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-mtconnect-driver.md in a new git worktree |
| MQTT/Sparkplug (Wave 2) | Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-mqtt-sparkplug-driver.md in a new git worktree |
- Slash-command form (equivalent):
/superpowers-extended-cc:subagent-driven-development <plan-path>. - Parallel-session / resume form (batch execution with checkpoints, no fresh-subagent-per-task):
/superpowers-extended-cc:executing-plans <plan-path>— reads the same.tasks.jsonand continues from the first pending task. - Recommended order: Modbus RTU → SQL poll → MTConnect → MQTT/Sparkplug (lowest effort first; see each wave below for the gating notes). Run one plan per worktree; the four plans are independent, so separate worktrees may run concurrently.
Wave 0 — Universal Discover-backed browser 🟡
What it is. One generic DiscoveryDriverBrowser (+ CapturingAddressSpaceBuilder,
CapturedTreeBrowseSession, BrowserSessionService fallback, and the
ITagDiscovery.SupportsOnlineDiscovery gate) that turns any driver's ITagDiscovery.DiscoverAsync
into an AdminUI browse tree. It is the Wave-0 gate: every browsable new driver depends on this
seam, and it retrofits browse to the already-shipped AbCip / TwinCAT / FOCAS drivers for near-zero
marginal cost.
- Design:
2026-07-15-universal-discovery-browser-design.md - Implementation plan:
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.
- Merged to master —
- Outstanding: the full tree-render live
/rungate 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 - Implementation plan:
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
Transportselector (RTU CRC-16 + FC-aware length, no TxId) + artu_over_tcppymodbusdocker 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_tcpprofile 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 - Implementation plan:
2026-07-24-sql-poll-driver.md·.tasks.json— 22 tasks.ISqlDialectseam 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 seededSqlPollFixtureDB. The blackhole/timeout live-gate pauses a dedicatedmssqlcontainer — never the shared central SQL Server (it hostsConfigDb). - 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 - Implementation plan:
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→BadNoCommunicationmapping, ring-buffer re-baseline paging. - Fixture: canned XML unit fixtures (bulk of coverage) + a dockerized
mtconnect/cppagentintegration 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 — ✅ COMPLETE (P1 + P2, both live-gated)
- Design:
2026-07-15-mqtt-sparkplug-driver-design.md - Implementation plan:
2026-07-24-mqtt-sparkplug-driver.md·.tasks.json— 27 tasks, all done (P1 plain MQTT = Tasks 0–14; P2 Sparkplug B = Tasks 15–26). - Scope delivered: 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 +Grpc.Toolscodegen, birth/alias/seq-gap/rebirth state machine, death→STALE, birth-driven browse tree, scoped Request-rebirth NCMD, typed tag editor). Write-through (IWritable) is deferred to P3 — every MQTT node materializes read-only. - Fixture: Mosquitto TLS+auth broker + JSON publisher sidecar, live at
10.100.0.35:8883(:1883plaintext-but-authenticated); stack/opt/otopcua-mqtt, compose intests/Drivers/….Driver.Mqtt.IntegrationTests/Docker/. Env-gated live suite (MQTT_FIXTURE_ENDPOINT, seeinfra/README.md§3). The project-owned C# Sparkplug edge-node simulator (--profile sparkplug, groupOtOpcUaSim, nodesEdgeA/EdgeB,EdgeAdeviceFiller1) shipped with P2 and answers rebirth NCMDs. - P2 status (2026-07-25): Tasks 15–26 complete. Offline 1528 passed / 0 failed (581 driver · 809 AdminUI · 138 Core.Abstractions); live 15/15 against the broker + simulator.
- P1 live gate found two AdminUI authoring gaps (the gate's whole point — both invisible to green
unit tests, because AdminUI has no bUnit and nothing tied these hand-maintained lists to
DriverTypeNames):- FIXED —
RawDriverTypeDialog's option array never got an MQTT row, so no operator could create an MQTT driver at all. Fixed, plus a reflection parity guard (RawDriverTypeDialogParityTests) that fails for any futureDriverTypeNamesentry missing from the picker. - FIXED —
MqttDriverForm/MqttDeviceFormdid not exist, so the broker connection was unauthorable from the AdminUI. Both shipped after the P1 gate.
- FIXED —
- P2 live gate found a third one, of the same family — FIXED:
MqttDriverFormstill carried its P1 Sparkplug placeholder. Switching Mode toSparkplugBrendered a "not available yet" notice and no Group ID field — so once Sparkplug ingest shipped there was still no way to author a Sparkplug driver from the AdminUI, andSparkplug.GroupIdis the driver's entire subscription filter (spBv1.0/{GroupId}/#): blank ⇒ connected,Healthy, ingesting nothing. Fixed (all five Sparkplug keys, merge-not-replace on save, group-id segment validation) + 8 falsifiableMqttDriverFormModelTestscases. Three gates, three instances of the same defect class: a hand-maintained AdminUI surface left behind by a driver-side feature. - P2 live gate — two open UI gaps, documented not fixed (see
docs/drivers/Mqtt.md§Known gaps):- A completely empty browse tree cannot arm a rebirth. The scope comes from a tree click, so on
a plant that has not birthed since the window opened
Request rebirth…stays disabled — the very case the affordance exists for.MqttBrowseSession.ResolveRebirthTargetsalready accepts a bare{group}/{edgeNode}("the prime rebirth target"); only a UI path to enter one is missing. Mitigation shipped: a Refresh button on the browse tree (it re-reads the same session, which previously rendered exactly once at open and never again).
- A completely empty browse tree cannot arm a rebirth. The scope comes from a tree click, so on
a plant that has not birthed since the window opened
- P2 live gate also found a PRE-EXISTING, cross-driver AdminUI defect — FIXED: every node label in
the shared
DriverBrowseTreewas an<a href="#">. In a Blazor Web App,blazor.web.js's enhanced-navigation click interceptor resolves a bare#against<base href="/">, so clicking any browse-tree node label navigated the whole AdminUI to/, tore down the circuit and destroyed the hosting modal — losing the browse session and the tag selection.@onclick:preventDefaultdoes not help: it suppresses the browser's default action, not Blazor's own interceptor. The labels are now<button type="button">. It dates to the component's introduction (2026-05-28) and affects every driver's picker; it only surfaced now because Sparkplug's Request-rebirth is the first feature that requires clicking a label rather than the ▶ toggle (always a<button>, always fine).- A metric name containing
/cannot be browse-committed. The derived raw tag Name is a RawPath segment and may not contain/, whilemetricNamelegitimately may — so the spec-mandatoryNode Control/Rebirthis refused at commit (Row N: Name must not contain '/'). The refusal is loud and all-or-nothing, never a silently mis-bound tag, and Manual entry is the working path (the Sparkplug tag editor accepts a slashed metric name). Fixing it needs a name-sanitisation policy with a collision answer, so it was recorded rather than guessed at. _canRebirthis captured at browse-open, so a reaped session still draws the button.- The tag editor injects the Plain-only
payloadFormatkey into a Sparkplug blob — cosmetic; the factory routes on the Sparkplug tuple and ignores it.
- A metric name containing
- Rediscovery is inert in v3 (platform gap, not MQTT's). The Sparkplug driver raises
OnRediscoveryNeededon a changed birth metric-set, but nothing subscribes to it andDriverHostActor.HandleDiscoveredNodeshard-returns. A DBIRTH introducing a metric does not change the OPC UA tree — redeploy. Driver-side behaviour is log-observable only. - Redundant-pair hazard (documented, not a code change): both nodes of a pair run the driver, so a
fixed
clientIdmakes them evict each other forever — the broker logsalready connected, closing old connectionand both nodes reconnect every ~2 s while still reportingHealthy. Unset (the default) is correct.MqttDriverFormwarns inline the moment a value is typed. - Effort: M–L, delivered. The MQTTnet-5/net10 + central-pinning risk is retired (Task 0's spike held through both phases); Sparkplug state-machine correctness was carried by golden-payload vectors + the live simulator.
Next actions
- Close the Wave-0 live gate (Gitea #468) — unblocks browse verification for MTConnect/BACnet.
- Build Wave 1 — Modbus RTU first (lowest effort, no new infra), then SQL poll. Both plans are 📝 ready; run the command from the Executing a plan table (subagent-driven-development in a git worktree).
- Build Wave 2 — MTConnect's browse leg wants #468 closed first. MQTT / Sparkplug B is
COMPLETE (P1 + P2, both live-gated); its only remaining work is optional — P3 write-through
(
IWritable: NCMD/DCMD + plain publish) and the two browse-UI gaps recorded above.
Update this file's Summary table and per-wave status whenever a deliverable changes state.