docs(mqtt): Sparkplug P2 live-verified; MQTT driver complete

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
This commit is contained in:
Joseph Doherty
2026-07-25 01:11:56 -04:00
parent 8d9155682d
commit d5bd4226ee
11 changed files with 685 additions and 84 deletions
@@ -70,7 +70,22 @@
else
{
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="chip chip-ok">Browser open</span>
<div class="d-flex align-items-center gap-2">
<span class="chip chip-ok">Browser open</span>
@* An observation window ACCUMULATES — the session keeps recording every
message after the tree was first rendered — but the tree itself loads
exactly once, when it is created. Without this the operator sees the
t=0 snapshot forever: on MQTT the first render is usually empty (a
topic that has not published yet is invisible) and on Sparkplug it is
almost always empty, because births are never retained. Re-keying the
tree re-runs its root load against the SAME session, so nothing
reconnects and nothing already observed is lost. *@
<button type="button" class="btn btn-outline-secondary btn-sm"
title="Re-read what this session has observed since it opened"
@onclick="RefreshTree">
Refresh
</button>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="raw-browse-mirror"
checked="@_createGroups" @onchange="@(e => _createGroups = e.Value is true)" />
@@ -80,7 +95,7 @@
</div>
</div>
<DriverBrowseTree SessionToken="_token" MultiSelect="true"
<DriverBrowseTree @key="_treeGeneration" SessionToken="_token" MultiSelect="true"
SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync"
OnNodeSelectedWithPath="OnScopeNodeSelected" />
@@ -245,6 +260,12 @@
private bool _busy;
private List<string> _commitErrors = new();
/// <summary>
/// Bumped to force a fresh <c>DriverBrowseTree</c> against the same session — see the Refresh
/// button's remarks. Seeded from the session so re-opening a modal always starts a new generation.
/// </summary>
private int _treeGeneration;
// Request-rebirth affordance (MQTT/Sparkplug only). _canOperate is defence in depth — the real gate
// is server-side in BrowserSessionService.RequestRebirthAsync, which fails closed.
private bool _canOperate;
@@ -519,6 +540,24 @@
}
}
/// <summary>
/// Re-reads the root of the open session's observed tree. Only the tree is rebuilt: the session,
/// its broker connection and everything it has recorded are untouched, so this is strictly a
/// re-render and never re-observes from scratch.
/// </summary>
/// <remarks>
/// The tag <b>selection</b> is deliberately kept — <c>_selectedIds</c> is keyed by browse node id
/// and a refresh does not renumber anything, so an operator who ticked leaves, waited for more
/// of the plant to appear, and refreshed does not silently lose the ticks. The rebirth scope IS
/// cleared, because the node it pointed at may no longer be in the rebuilt tree and a stale
/// armed scope is exactly the thing that panel's two-click confirm exists to prevent.
/// </remarks>
private void RefreshTree()
{
_treeGeneration++;
ResetRebirthState();
}
/// <summary>Clears every rebirth-panel field (modal re-open, browser close).</summary>
private void ResetRebirthState()
{