d5bd4226ee
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
217 lines
17 KiB
Markdown
217 lines
17 KiB
Markdown
# 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) | ✅ **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.json` and continues
|
||
from the first pending task.
|
||
- **Recommended order:** Modbus RTU → SQL poll → MTConnect → MQTT/Sparkplug (lowest effort first;
|
||
see each wave below for the gating notes). Run one plan per worktree; the four plans are
|
||
independent, so separate worktrees may run concurrently.
|
||
|
||
---
|
||
|
||
## Wave 0 — Universal Discover-backed browser 🟡
|
||
|
||
**What it is.** One generic `DiscoveryDriverBrowser` (+ `CapturingAddressSpaceBuilder`,
|
||
`CapturedTreeBrowseSession`, `BrowserSessionService` fallback, and the
|
||
`ITagDiscovery.SupportsOnlineDiscovery` gate) that turns any driver's `ITagDiscovery.DiscoverAsync`
|
||
into an AdminUI browse tree. It is the **Wave-0 gate**: every browsable new driver depends on this
|
||
seam, and it retrofits browse to the already-shipped AbCip / TwinCAT / FOCAS drivers for near-zero
|
||
marginal cost.
|
||
|
||
- **Design:** [`2026-07-15-universal-discovery-browser-design.md`](2026-07-15-universal-discovery-browser-design.md)
|
||
- **Implementation plan:** [`2026-07-15-universal-discovery-browser-implementation.md`](2026-07-15-universal-discovery-browser-implementation.md) · [`.tasks.json`](2026-07-15-universal-discovery-browser-implementation.md.tasks.json)
|
||
- **Status: 🟡 code-complete + merged, live gate open.**
|
||
- Merged to master — `056887d6` (*Merge feat/universal-discovery-browser — Wave-0 universal Discover-backed browser*), 2026-07-15.
|
||
- Implementation plan: **19 / 19 tasks completed.**
|
||
- Lit up AbCip / TwinCAT / FOCAS pickers with zero per-driver browse code.
|
||
- **Outstanding:** the full tree-render live `/run` gate is **fixture-blocked** — tracked as **Gitea #468**. Complete this before leaning on browse in Wave 2/3.
|
||
|
||
---
|
||
|
||
## Wave 1 — low-effort / high-leverage pair 📝
|
||
|
||
Both are **fully CI-simulatable with zero new hardware**; Modbus RTU needs no new infra at all, and
|
||
SQL poll reuses the always-on central SQL Server. This is the recommended next build.
|
||
|
||
### Modbus RTU — 📝 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 — ✅ **COMPLETE** (P1 + P2, both live-gated)
|
||
- **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, 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.Tools` codegen, 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`
|
||
(`:1883` plaintext-but-authenticated); stack `/opt/otopcua-mqtt`, compose in
|
||
`tests/Drivers/….Driver.Mqtt.IntegrationTests/Docker/`. Env-gated live suite
|
||
(`MQTT_FIXTURE_ENDPOINT`, see `infra/README.md` §3). The **project-owned C# Sparkplug edge-node
|
||
simulator** (`--profile sparkplug`, group `OtOpcUaSim`, nodes `EdgeA`/`EdgeB`, `EdgeA` device
|
||
`Filler1`) 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`):
|
||
1. **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* future `DriverTypeNames` entry
|
||
missing from the picker.
|
||
2. **FIXED —** `MqttDriverForm` / `MqttDeviceForm` did not exist, so the broker connection was
|
||
unauthorable from the AdminUI. Both shipped after the P1 gate.
|
||
- **P2 live gate found a third one, of the same family — FIXED:** `MqttDriverForm` still carried its
|
||
P1 Sparkplug **placeholder**. Switching Mode to `SparkplugB` rendered 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**, and `Sparkplug.GroupId` is 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 falsifiable
|
||
`MqttDriverFormModelTests` cases. **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.ResolveRebirthTargets` already 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).
|
||
- **P2 live gate also found a PRE-EXISTING, cross-driver AdminUI defect — 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. 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 `/`, while `metricName` legitimately may — so the
|
||
spec-mandatory `Node Control/Rebirth` is 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.
|
||
- `_canRebirth` is captured at browse-open, so a reaped session still draws the button.
|
||
- The tag editor injects the Plain-only `payloadFormat` key into a Sparkplug blob — cosmetic; the
|
||
factory routes on the Sparkplug tuple and ignores it.
|
||
- **Rediscovery is inert in v3 (platform gap, not MQTT's).** The Sparkplug driver raises
|
||
`OnRediscoveryNeeded` on a changed birth metric-set, but **nothing subscribes to it** and
|
||
`DriverHostActor.HandleDiscoveredNodes` hard-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 `clientId` makes them evict each other forever** — the broker logs
|
||
`already connected, closing old connection` and both nodes reconnect every ~2 s while still
|
||
reporting `Healthy`. Unset (the default) is correct. `MqttDriverForm` warns 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
|
||
|
||
1. **Close the Wave-0 live gate** (Gitea #468) — unblocks browse verification for MTConnect/BACnet.
|
||
2. **Build Wave 1** — Modbus RTU first (lowest effort, no new infra), then SQL poll. Both plans are
|
||
📝 ready; run the command from the [Executing a plan](#executing-a-plan-git-worktree--subagent-per-task)
|
||
table (subagent-driven-development in a git worktree).
|
||
3. **Build Wave 2** — MTConnect'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.
|