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
+5
View File
@@ -216,6 +216,11 @@ lmxopcua-fix sync modbus # rsync this repo's tests/.../Docker/
Also needs `MQTT_FIXTURE_USERNAME` / `MQTT_FIXTURE_PASSWORD`, and `MQTT_FIXTURE_CA_CERT` to pin the
fixture CA (`scp dohertj2@10.100.0.35:/opt/otopcua-mqtt/secrets/ca.crt`). A co-running publisher loops
JSON under `otopcua/fixture/…`.
- MQTT **Sparkplug B**: the same broker, plus the `sparkplug` compose profile's `otopcua-sparkplug-sim`
(project-owned C# edge-node simulator) — group **`OtOpcUaSim`**, edge nodes **`EdgeA`**/**`EdgeB`**,
`EdgeA` device **`Filler1`**, ~2 s cadence. It subscribes to `spBv1.0/OtOpcUaSim/NCMD/{node}` and
re-births on request. **Births are never retained**, so `docker restart otopcua-sparkplug-sim` is how
you force a fresh one while a browse window is open.
Override any endpoint via the env var to point at a real PLC. The local OtOpcUa server runs on this VM at `opc.tcp://localhost:4840`**that's not on the docker host**.
+34
View File
@@ -0,0 +1,34 @@
# Isolated MQTT/Sparkplug live-gate overlay (Task 26).
#
# Runs the MAIN central pair ONLY, from this worktree's image, under its own
# compose project + offset host ports, so the shared `otopcua-dev` rig and the
# sibling driver worktrees that share `otopcua-host:dev` are untouched.
#
# docker build -f docker-dev/Dockerfile --target runtime -t otopcua-host:mqtt .
# docker compose -p otopcua-mqtt -f docker-dev/docker-compose.yml \
# -f docker-dev/docker-compose.mqtt.yml \
# up -d sql migrator cluster-seed central-1 central-2
#
# AdminUI -> http://localhost:9210 (login disabled; auto-admin)
# OPC UA -> opc.tcp://localhost:4850 (central-1) / :4851 (central-2)
# SQL -> localhost,14350
#
# `!override` on every `ports` list: compose MERGES list-valued keys by
# appending, so a plain re-declaration keeps the base file's "14330:1433" /
# "4840:4840" and collides with the running `otopcua-dev` rig.
services:
sql:
ports: !override
- "14350:1433"
central-1:
image: otopcua-host:mqtt
ports: !override
- "4850:4840"
- "9210:9000"
central-2:
image: otopcua-host:mqtt
ports: !override
- "4851:4840"
+40 -11
View File
@@ -2,15 +2,18 @@
Coverage map + gap inventory for the MQTT driver's harness.
**TL;DR:** the unit suite (266 tests) carries the mapping, indexing and failure-classification logic;
the live suite (7 tests) proves the parts only a real broker can prove — TLS, real authentication, CA
pinning in both directions, retained-message seeding, and that a rejected CONNACK is actually
surfaced. Sparkplug B has **no fixture at all** — it is unimplemented (P2).
**TL;DR:** the unit suite (581 tests) carries the mapping, indexing, Sparkplug state-machine and
failure-classification logic; the live suite (15 tests) proves the parts only a real broker can prove
— TLS, real authentication, CA pinning in both directions, retained-message seeding, that a rejected
CONNACK is actually surfaced, and the Sparkplug birth/alias/rebirth/death path against a real
edge-node simulator.
## What the fixture is
`eclipse-mosquitto:2.0.22` plus a second container of the same image running `publisher/publish.sh`
(the image ships `mosquitto_pub`, so no bespoke image is needed). Compose lives at
(the image ships `mosquitto_pub`, so no bespoke image is needed), plus — behind the `sparkplug`
compose profile — **`otopcua-sparkplug-sim`**, a project-owned C# Sparkplug-B edge-node simulator
(`tests/Drivers/….Driver.Mqtt.IntegrationTests/SparkplugSimulator/`). Compose lives at
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/`; the deployed stack is
`/opt/otopcua-mqtt` on the shared Docker host.
@@ -48,15 +51,38 @@ wrong password is rejected **and surfaced** (the MQTTnet-5 silent-`Healthy` defe
subscribe→value under a RawPath; retained message seeds a value on subscribe; and an unauthored topic
stays silent while an authored tag keeps flowing.
### The Sparkplug simulator
Group **`OtOpcUaSim`**; edge nodes **`EdgeA`** and **`EdgeB`**; `EdgeA` additionally publishes device
**`Filler1`**. Node metrics `Temperature` (Float), `Pressure` (Double), `Count` (Int32), `Running`
(Boolean), `Serial` (String) plus the mandatory `bdSeq` and **`Node Control/Rebirth`** — the last one
deliberately carries a `/`, so it exercises the "a metric name is not a topic segment" rule. Device
metrics: `Temperature` (Float), `FillCount` (Int64), `Jammed` (Boolean). Cadence ~2 s.
It **subscribes to `spBv1.0/OtOpcUaSim/NCMD/{node}`** and re-births on a rebirth request, so it
exercises the real NCMD round trip rather than simulating it. Births are not retained (per spec), so
`docker restart otopcua-sparkplug-sim` is the way to force a fresh birth on demand:
```bash
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt && docker compose --profile sparkplug up -d'
ssh dohertj2@10.100.0.35 'docker restart otopcua-sparkplug-sim' # force a birth
ssh dohertj2@10.100.0.35 'docker logs -f otopcua-sparkplug-sim'
```
Live-gated end-to-end (2026-07-24, P1 milestone): deployed on the docker-dev rig against this fixture,
both tags resolved and served changing values through OPC UA, and the AdminUI `#`-observation picker
rendered the real topic tree.
rendered the real topic tree. Re-gated for P2 (2026-07-25) against the Sparkplug simulator — see
[Mqtt.md](Mqtt.md).
## What it does NOT cover
### 1. Sparkplug B — entirely
No edge-node simulator exists. Birth/alias/seq-gap/rebirth/death are P2 (Tasks 1526) and the
project-owned C# simulator is part of that work.
### 1. Sparkplug primary-host STATE, and write-through
The simulator never observes a Host Application `STATE`, because the driver never publishes one
(`ActAsPrimaryHost` is unimplemented). Nor is there any DCMD/NCMD **command** coverage: rebirth is the
only NCMD the driver ever sends, and `IWritable` is deferred.
Sparkplug **seq-gap injection** is unit-proven only — the simulator always publishes a well-formed
monotonic `seq`, so no live test drives the gap → rebirth path end-to-end.
### 2. Broker-side failure injection
No test kills the broker mid-session, partitions the network, or forces a SUBACK failure against a
@@ -87,14 +113,17 @@ test. No automated coverage asserts that two concurrent drivers coexist.
| Does the driver ignore unauthored traffic? | ✅ yes |
| Does it survive a broker restart / flapping link? | ❌ unit-only |
| Does it hold up at plant tag counts? | ❌ no |
| Anything Sparkplug B | ❌ not implemented |
| Does a Sparkplug birth → alias → DATA → death cycle work? | ✅ yes (live, vs. the simulator) |
| Does an NCMD rebirth request actually get answered? | ✅ yes (the simulator subscribes + re-births) |
| Sparkplug seq-gap → rebirth | ❌ unit-only (the sim never skips a seq) |
| Sparkplug primary-host STATE / DCMD writes | ❌ not implemented |
## Follow-up candidates
1. Broker-restart / SUBACK-failure live tests (closes gap 2).
2. A live wildcard-across-reconnect test (closes gap 3).
3. A concurrent-two-driver test pinning the client-id hazard (closes gap 6).
4. The Sparkplug edge-node simulator + its rebirth/seq-gap/death matrix (P2).
4. A seq-gap injection switch on the simulator, closing the last unit-only Sparkplug leg.
## Key fixture / config files
+168 -14
View File
@@ -4,11 +4,19 @@ In-process MQTT **broker-subscribe** driver. It holds one MQTTnet 5 client again
subscribes to the topics its authored tags name, and forwards each PUBLISH straight to
`ISubscribable.OnDataChange`. Ingest is **push, not poll**, and **one-way** — there is no `IWritable`.
Sparkplug B is **P2** and not implemented: `mode: SparkplugB` is a placeholder everywhere it appears.
**Two ingest shapes, selected by the driver's `Mode`:** `Plain` binds a tag to a concrete MQTT topic;
`SparkplugB` binds it to a `group / edge node / device? / metric` tuple and decodes Eclipse-Tahu
protobuf under one `spBv1.0/{GroupId}/#` subscription. Both run over the same single client.
See [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) for the harness and
[`../plans/2026-07-15-mqtt-sparkplug-driver-design.md`](../plans/2026-07-15-mqtt-sparkplug-driver-design.md)
for the design.
> **Status.** P1 (plain MQTT) and P2 (Sparkplug B ingest) are both shipped and live-gated against the
> Mosquitto + Sparkplug-simulator fixture. Write-through (`IWritable` — NCMD/DCMD/plain publish) is
> **deferred**; every MQTT node materializes read-only. See
> [Known gaps](#known-gaps) for what is wired but not yet end-to-end observable.
## Project Layout
| Project | Holds |
@@ -33,15 +41,15 @@ public sealed class MqttDriver
| Capability | Entry point | Notes |
|---|---|---|
| `IDriver` | `InitializeAsync` / `ReinitializeAsync` / `ShutdownAsync` / `GetHealth` | Order **register → attach → connect** is contractual. `ReinitializeAsync` applies a tag-only delta in place, and rebuilds the session when the broker identity changed |
| `ITagDiscovery` | `DiscoverAsync` | Replays **only the authored `rawTags`** into an `Mqtt` folder. `SupportsOnlineDiscovery => false`, `RediscoverPolicy => Once`*a broker's live topic set is not a tag catalogue* |
| `ITagDiscovery` | `DiscoverAsync` | Replays **only the authored `rawTags`** into an `Mqtt` folder. `SupportsOnlineDiscovery => false`. `RediscoverPolicy` is `Once` in Plain and **`UntilStable` in Sparkplug** (half of what a Sparkplug tag needs — its datatype — is only knowable from a birth) |
| `ISubscribable` | `SubscribeAsync` / `OnDataChange` | One SUBSCRIBE per distinct authored topic. **`publishingInterval` is ignored** — MQTT is push-based; this driver neither polls nor throttles |
| `IReadable` | `ReadAsync` | Serves the last received/retained value from `LastValueCache`; never throws. A never-seen tag reads `BadWaitingForInitialData` |
| `IHostConnectivityProbe` | `GetHostStatuses` | 1-second poll of `MqttConnection.State`; `HostName` is `"{host}:{port}"` |
| `IRediscoverable` | `OnRediscoveryNeeded` | **Seam only** — nothing raises it in Plain mode. Sparkplug DBIRTH will |
| `IRediscoverable` | `OnRediscoveryNeeded` | Nothing raises it in Plain mode. **Sparkplug raises it** when a birth for an *authored* scope carries a metric-name set different from the last one seen for that scope — both conditions are anti-storm, see [Rediscovery](#rediscovery-sparkplug) |
**Not implemented:** `IWritable`, `IPerCallHostResolver`, `IAlarmSource`, `IHistoryProvider`.
Materialized nodes are `SecurityClassification.ViewOnly`, `IsAlarm: false`, `IsHistorized: false`
*"MQTT ingest is one-way in P1 … ViewOnly rather than an Operate node whose writes would silently vanish."*
*"MQTT ingest is one-way … ViewOnly rather than an Operate node whose writes would silently vanish."*
Driver-type string: **`Mqtt`** (`DriverTypeNames.Mqtt`).
@@ -89,8 +97,38 @@ driver are pure organisational grouping in the RawPath.
{}
```
The form never writes `RawTags` (the deploy artifact owns it) and never touches `Sparkplug` (P2) — an
existing `Sparkplug` object, and any key a newer driver adds, survive a load→save untouched.
### `Sparkplug` sub-object (`Mode: "SparkplugB"`)
```jsonc
{
"Mode": "SparkplugB",
"Sparkplug": {
"GroupId": "OtOpcUaSim", // REQUIRED — the driver's whole subscription scope
"HostId": "", // Host Application identity; receive-only (see below)
"ActAsPrimaryHost": false, // NOT IMPLEMENTED — logs a warning when true
"RequestRebirthOnGap": true, // a seq gap asks the edge node to re-announce (NCMD)
"BirthObservationWindowSeconds": 15 // how long discovery collects births before calling it stable
}
}
```
| Key | Default | Notes |
|---|---|---|
| `GroupId` | `""` | **Required.** The driver subscribes exactly `spBv1.0/{GroupId}/#`. Blank ⇒ it connects, reports `Healthy`, and ingests **nothing**. It is a literal topic segment, so `/`, `+`, `#` are refused by the form |
| `HostId` | `""` | Sparkplug Host Application identity. **Receive-only** — the driver observes `STATE` and never publishes one |
| `ActAsPrimaryHost` | `false` | **Not implemented.** Setting it logs a startup **warning** rather than being silently inert — being a primary host is a three-part contract (retained `ONLINE`, a matching `OFFLINE` registered as the MQTT Will *at connect time*, an explicit `OFFLINE` on clean shutdown), and shipping the `ONLINE` half without the Will is strictly worse than shipping neither: a crashed node would leave a retained `ONLINE` asserting forever that a dead host is alive |
| `RequestRebirthOnGap` | `true` | On a detected sequence-number gap, publish a rebirth-request NCMD instead of running on stale metric state |
| `BirthObservationWindowSeconds` | `15` | Discovery's birth-collection window. Must be ≥ 1 |
`MqttDriverForm` authors all five in Sparkplug mode. The sub-object is **merged, never replaced** (a
key a newer driver adds inside it survives an older AdminUI), and in `Plain` mode it is not touched at
all — so flipping a driver to Plain to look at it and back does not lose the group id. The form never
writes `RawTags`; the deploy artifact owns it.
> **This was a live-gate finding.** Through the P2 implementation `MqttDriverForm` still carried its P1
> placeholder — switching Mode to `SparkplugB` rendered a "not available yet" notice and **no group-id
> field**, so once Sparkplug ingest shipped there was no way to author a Sparkplug driver from the
> AdminUI at all. Pinned by `MqttDriverFormModelTests`' Sparkplug cases.
Every key has a default, so nothing is strictly required by the binder. Notable ones:
`port` **8883**, `useTls` **true**, `protocolVersion` **V500**, `cleanSession` **true**,
@@ -113,6 +151,14 @@ a `connectTimeoutSeconds: 0` driver-brick.
## Tag Configuration
A tag binds in **one of two shapes**, and which one is decided by *which keys are present*, not by a
`mode` key on the tag. `MqttTagDefinitionFactory` reads the Sparkplug tuple when any Sparkplug
descriptor key is set, and the topic otherwise; the AdminUI's "Tag shape" selector infers the same way
on reopen. The key names are single-sourced in `MqttTagConfigKeys` so the browse-commit mapper and the
factory cannot drift.
### Plain shape
| Key | Type | Default | Notes |
|---|---|---|---|
| `topic` | string | — | **Required.** Absent/blank rejects the tag ⇒ `BadNodeIdUnknown` |
@@ -141,6 +187,69 @@ deliberately stricter than the driver, because one Tag holds one value and a wil
from many source topics. Wildcard tags are indexed **by filter**, not by concrete topic, so they
survive a reconnect; a message fans out to *every* matching tag, and unauthored topics are dropped.
### Sparkplug shape
| Key | Type | Default | Notes |
|---|---|---|---|
| `groupId` | string | — | **Required.** Must match the driver's `Sparkplug.GroupId` to ever receive a message |
| `edgeNodeId` | string | — | **Required.** |
| `deviceId` | string | *absent* | **Omit** for a metric published by the edge node itself (NBIRTH/NDATA). Absent is meaningfully different from `""` |
| `metricName` | string | — | **Required.** The tag's stable binding key across rebirths. **May contain `/`**`Node Control/Rebirth` and `Properties/Serial Number` are canonical Sparkplug names, and the whole name is one value, never split |
| `dataType` | `DriverDataType` | *from the birth* | **Optional override.** Absent ⇒ take whatever type the birth certificate declared. The AdminUI's "(from birth certificate)" option removes the key entirely |
| `isHistorized` / `historianTagname` | — | — | Server-side historization, unchanged from Plain |
`payloadFormat`, `jsonPath`, `qos` and `retainSeed` are **Plain-only** and meaningless here: a
Sparkplug body is protobuf, and the subscription is one driver-wide `spBv1.0/{GroupId}/#`, not a
per-tag filter.
`groupId` / `edgeNodeId` / `deviceId` are literal MQTT topic segments, so the AdminUI editor refuses
`/`, `+` and `#` in them — a decoded incoming id can never contain one, so such a tag could never
bind. `metricName` is deliberately **exempt**: it is not a topic segment.
```jsonc
// A device metric.
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"FillCount"}
// A node-level metric — note deviceId is ABSENT, not blank.
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temperature"}
```
## Sparkplug B Ingest
One subscription (`spBv1.0/{GroupId}/#`) feeds a per-edge-node state machine
(`SparkplugIngestor` + `SparkplugCodec` / `AliasTable` / `BirthCache` / `SequenceTracker`):
- **Births are the only self-describing message.** NBIRTH/DBIRTH name every metric and assign its
alias; NDATA/DDATA carry an **alias and no name at all**. So the alias table is rebuilt *per birth*
and tags bind **by name**, never by alias — aliases are per-birth and may be reused for a different
metric after a rebirth.
- **NBIRTH is node-scoped, DBIRTH is device-scoped**, and routing a DBIRTH through the node path
wipes `bdSeq`. They are handled separately on purpose.
- **Sequence tracking**: `seq` is a wrapping 0255 counter. A detected gap raises a rebirth request
when `RequestRebirthOnGap` is on, rather than silently continuing on stale metric state.
- **Death → STALE.** An NDEATH whose `bdSeq` matches the live session (the bdSeq tie is what stops a
stale death from killing a fresh session) marks every metric under that edge node `Bad` /
`BadNoCommunication`. A DDEATH does the same for one device. The next birth restores them.
- **Unsupported datatypes are skipped with a warning, never guessed**: `DataSet`, `Template`,
`PropertySet`, `PropertySetList`, `Unknown`. `Int8``Int16` and `UInt8``UInt16` widen; `*Array`
variants map to the element type with the array bit set.
- **Ingest state is reset at every session/authoring seam** — a reconnect or a `ReinitializeAsync`
must not carry a previous session's alias table into a new one.
### Rediscovery (Sparkplug)
`OnRediscoveryNeeded` fires only when a birth is **for an authored scope** *and* its metric-name set
**differs** from the last one seen for that scope (order-insensitive, ordinal). Both conditions are
anti-storm, not optimisations: rediscovery triggers an address-space rebuild, and an edge node
re-births freely — on its own schedule, on every reconnect, and once per rebirth NCMD.
> ⚠️ **Nothing consumes the signal today.** No runtime component subscribes to
> `IRediscoverable.OnRediscoveryNeeded`, and `DriverHostActor.HandleDiscoveredNodes` hard-returns —
> discovered-node injection is dormant in v3. The served address space comes from the deploy artifact,
> so **a DBIRTH introducing a new metric does not change the OPC UA tree**; you must redeploy. The
> driver's decision is observable only as an Information log line
> (`… requesting rediscovery.`). This is a v3 platform gap, not an MQTT one.
## Connection + Failure Semantics
`MqttConnectionState`: `Disconnected → Connected → Reconnecting → Faulted → Disposed`.
@@ -213,6 +322,32 @@ and only** browse action that publishes anything.
- Offered only for a Sparkplug session (`IRebirthCapableBrowseSession.RebirthAvailable`); a Plain
MQTT window publishes nothing, ever.
### Refresh
An observation window **accumulates**, but the tree renders **once**, when it is created — and
`OpenAsync` returns as soon as the SUBSCRIBE is granted, without waiting for the birth-observation
window. So the first render is normally empty on Plain (a topic that has not published yet is
invisible) and *almost always* empty on Sparkplug (births are never retained). **Refresh** re-reads
the root against the same session: nothing reconnects, nothing already observed is lost, and the tag
selection is kept. Only the armed rebirth scope is cleared, because the node it named may no longer be
in the rebuilt tree.
> **Also fixed by this gate, and not MQTT-specific:** every node label in the shared
> `DriverBrowseTree` was an `<a href="#">`. `blazor.web.js`'s enhanced-navigation click interceptor
> resolves a bare `#` against `<base href="/">`, so clicking a node **label** navigated the whole
> AdminUI to `/` and destroyed the hosting modal (`@onclick:preventDefault` suppresses the browser's
> default action, not Blazor's interceptor). The labels are now `<button type="button">`. This
> affected **every** driver's picker since 2026-05-28; it surfaced here because choosing a rebirth
> scope is the first flow that requires clicking a label rather than the ▶ toggle.
> ⚠️ **Known gap — a completely empty tree cannot arm a rebirth.** The rebirth scope is taken from the
> last node clicked *in the tree*, so on a plant that has not birthed since the window opened there is
> nothing to click and `Request rebirth…` stays disabled — the exact case the affordance exists for.
> `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare `{group}/{edgeNode}` for a node the
> window has never seen (it calls that "the prime rebirth target"); what is missing is a UI path to
> enter one, or to default the scope to the driver's own configured `GroupId`. Until then: use
> **Refresh** after any birth, or author the tags by **Manual entry**, which is always available.
## Test Connect
`MqttDriverProbe` performs **one CONNECT** (no SUBSCRIBE, no publish) under a `-probe-` client id and
@@ -222,17 +357,36 @@ identical words. See [TestConnectProbes.md](TestConnectProbes.md).
## Testing
- **Unit** — `tests/Drivers/…Driver.Mqtt.Tests` (266 tests): tag-config mapping, JSONPath subset,
strict-enum rejection, subscription indexing (incl. the wildcard-by-filter case), reconnect/rebuild
branches, CONNACK classification.
- **Live (env-gated)** — `tests/Drivers/…Driver.Mqtt.IntegrationTests` (7 tests) against the Mosquitto
fixture; skips cleanly when `MQTT_FIXTURE_ENDPOINT` is unset. See
[Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) and `infra/README.md` §3.
- **Unit** — `tests/Drivers/…Driver.Mqtt.Tests` (**581 tests**): tag-config mapping (both shapes),
JSONPath subset, strict-enum rejection, subscription indexing (incl. the wildcard-by-filter case),
reconnect/rebuild branches, CONNACK classification, and the Sparkplug half — golden-payload decode,
topic parse/format, datatype map, alias/birth-cache rebuild, sequence + bdSeq-death ties, the
rediscovery change gate, and the browse session's "publishes nothing" assertion.
- **Live (env-gated)** — `tests/Drivers/…Driver.Mqtt.IntegrationTests` (**15 tests**) against the
Mosquitto broker + the C# Sparkplug edge-node simulator; skips cleanly when `MQTT_FIXTURE_ENDPOINT`
is unset. See [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) and `infra/README.md` §3.
- **AdminUI** — `MqttDriverFormModelTests` / `MqttTagConfigModelTests` cover both editors'
round-trip, mode inference and validation. AdminUI has no bUnit, so the razor shells themselves are
only ever proven by a live `/run` gate.
## Operational Notes
- **Read-only.** No writes reach the broker; every node is ViewOnly.
- **Read-only.** No writes reach the broker; every node is ViewOnly. Sparkplug NCMD is used for
**rebirth requests only** — never to command a device.
- **No alarms, no driver-side history.** Historization is a server-side concern (`isHistorized`).
- **Both pair nodes subscribe independently** — MQTT fan-out is per-connection, and the Primary gate
applies downstream, not to ingest.
applies downstream, not to ingest. This is also why `clientId` must stay unset (above).
- **Unauthored traffic is ignored.** A busy broker is normal; the driver never auto-provisions a tag.
## Known gaps
| Gap | Effect | Notes |
|---|---|---|
| **Write-through (`IWritable`)** | Every MQTT node is read-only | Deferred to P3 — NCMD/DCMD + plain publish, optimistic-Good + self-correct on echo |
| **Rediscovery is inert** | A DBIRTH introducing a metric does not change the OPC UA tree; redeploy to pick it up | v3 platform gap — nothing subscribes to `OnRediscoveryNeeded` and `DriverHostActor.HandleDiscoveredNodes` hard-returns. Driver-side decision is log-observable |
| **Rebirth needs a non-empty tree** | `Request rebirth…` cannot be armed on a plant that has not birthed since the window opened | See [Refresh](#refresh). Session side already supports a bare `{group}/{edgeNode}` scope |
| **`_canRebirth` is captured at browse-open** | A session reaped for idleness (2 min) still draws the button, and using it errors | The error is shown, not swallowed; reopen the browser |
| **Primary-host STATE publishing** | `ActAsPrimaryHost` does nothing | Logs a startup warning rather than being silently inert — see the `Sparkplug` sub-object table |
| **A metric whose name contains `/` cannot be browse-committed** | The commit is refused **whole**, in words (`Row N: Name must not contain '/'`) | The derived **tag Name** is a RawPath segment and may not contain `/`, while `metricName` legitimately may. This blocks the spec-mandatory `Node Control/Rebirth`. **Workaround: Manual entry** — author the tag with a `/`-free name and type the slashed `metricName` into the Sparkplug editor, which accepts it. Nothing is silently mis-bound |
| **`DataSet` / `Template` / `PropertySet` metrics** | Skipped with a warning | v1-unsupported; never coerced into a guessed type |
| **The tag editor writes `payloadFormat` into a Sparkplug blob** | Cosmetic only | `payloadFormat` is Plain-only; the factory routes on the Sparkplug keys and ignores it. Noise in the blob, not a mis-binding |
@@ -33,7 +33,7 @@ it reaches 📝.
| **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) | SM | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | 📝 **Plan ready** (23 tasks) | SM | Dockerized `mtconnect/cppagent` — CI-simulatable |
| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | ✅ **P1 done** (Tasks 014, live-gated) · 📝 P2 Sparkplug plan ready (Tasks 1526) | ML | Mosquitto TLS+auth fixture **live** at `10.100.0.35:8883` |
| **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 026) | ML | 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
@@ -130,46 +130,76 @@ Both CI-simulatable on the shared docker host, no hardware.
integration fixture (env-gated). Depends on Wave 0's browse seam being live-verified (#468).
- **Effort:** SM (≈11.5 wk with TrakHound, ≈2.53 wk hand-rolled).
### MQTT / Sparkplug B — ✅ P1 done (live-gated) · 📝 P2 plan ready
### 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** (P1 plain MQTT = Tasks 014, a complete shippable milestone; P2 Sparkplug B = Tasks 1526). Task 0 is the mandatory MQTTnet-5/net10 + central-pinning validation spike.
- **Scope:** P1 plain MQTT (MQTTnet-5 connect/TLS/auth + hand-rolled reconnect, subscribe→
- **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 014; P2 Sparkplug B = Tasks 1526).
- **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, birth/alias/seq-gap/rebirth state machine, death→STALE).
(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 **C# Sparkplug edge-node simulator** is P2
work and does not exist yet.
- **P1 status (2026-07-24):** Tasks 014 complete. `.Contracts`/`.Driver`/`.Browser` triad + factory,
`DriverTypeNames.Mqtt`, host factory/probe registration, AdminUI typed tag editor + bespoke
`#`-observation browser. **Offline 1131 passed / 0 failed** (266 driver · 727 AdminUI · 138
Core.Abstractions); **live 7/7** against the fixture.
- **P1 live gate found two AdminUI authoring gaps** (the gate's whole point — both were invisible to
266 green unit tests, because AdminUI has no bUnit and nothing tied these hand-maintained lists to
(`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 1526 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`, 3 tests) that fails for *any* future `DriverTypeNames` entry
missing from the picker — so the next driver fails a test instead of a live gate.
2. **OPEN — `MqttDriverForm` / `MqttDeviceForm` do not exist.** `DriverConfigModal` and
`DeviceModal` dispatch on `DriverType` and fall through to *"No typed config form for driver
type Mqtt"* — and **neither modal has a raw-JSON fallback**, unlike the `/uns` TagModal. So an
operator can create an MQTT driver but cannot author its broker host/port/TLS/credentials
through the AdminUI at all. The P1 gate authored `DriverConfig`/`DeviceConfig` by direct SQL to
get past this. **P1 is not operator-complete until these two razor forms land** (a
`MqttDriverForm` + `MqttDeviceForm` pair modelled on the OpcUaClient ones, plus their
serialization tests). This is a plan omission, not a regression: no task ever covered the
driver/device forms — Task 6's "typed editor" was the *tag* editor.
- **Redundant-pair hazard worth knowing (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
(`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`. Leaving `clientId` unset (the default) is correct. Observed live; captured in
`docs/drivers/Mqtt.md`. A future `MqttDriverForm` should not offer a free-text client id without
this warning.
- **Effort:** ML. **Top risks (P2):** Sparkplug state-machine correctness; MQTTnet-5/net10 + the repo's
fragile central pinning (mitigated by hand-rolling Tahu over one MQTTnet-5 client) — the pinning risk
is **retired**: P1 shipped on MQTTnet 5 with the repo's central pinning intact.
reporting `Healthy`. Unset (the default) is correct. `MqttDriverForm` warns inline the moment a
value is typed.
- **Effort:** ML, 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.
---
@@ -179,8 +209,8 @@ Both CI-simulatable on the shared docker host, no hardware.
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 P1 is done and
live-gated**; the remaining MQTT work is **P2 Sparkplug B** (Tasks 1526), whose first step is the
vendored Tahu proto + the project-owned C# edge-node simulator the P1 fixture does not include.
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.
+1
View File
@@ -70,6 +70,7 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live
| OpcUaClient | `mcr.microsoft.com/iotedge/opc-plc:2.14.10` | `opc.tcp://10.100.0.35:50000` | `docker compose up -d` |
| FOCAS | `otopcua-focas-sim` (vendored focas-mock) | `10.100.0.35:8193` | `docker compose up -d` (stack `~/otopcua-focas`) |
| MQTT | `eclipse-mosquitto:2.0.22` (+ a publisher sidecar) | `10.100.0.35:8883` TLS+auth · `:1883` plaintext+auth | `MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD=<pw> docker compose up -d` (stack `/opt/otopcua-mqtt`) |
| MQTT — Sparkplug B | `otopcua-sparkplug-sim` (project-owned C# edge-node simulator) | same broker; group **`OtOpcUaSim`**, edge nodes **`EdgeA`** / **`EdgeB`**, `EdgeA` device **`Filler1`**, ~2 s cadence | `docker compose --profile sparkplug up -d` (same stack). Answers rebirth NCMDs; restart it to force a fresh birth |
**Endpoint overrides** point a suite at a real PLC instead of the sim:
`MODBUS_SIM_ENDPOINT` · `AB_SERVER_ENDPOINT` · `S7_SIM_ENDPOINT` · `OPCUA_SIM_ENDPOINT` ·
@@ -141,17 +141,27 @@
{
<span style="width:18px"></span>
}
@* BUTTONS, NOT <a href="#">. This is a Blazor Web App: blazor.web.js installs a
document-level click interceptor for enhanced navigation, and it resolves a bare "#"
against <base href="/"> rather than the current URL — so clicking a node label
navigated the whole AdminUI to "/", tore down the circuit, and destroyed whatever modal
the tree was hosted in (losing the browse session AND the tag selection with it).
@onclick:preventDefault does not help: it suppresses the BROWSER's default action, not
Blazor's own interceptor. A <button> is never a navigation candidate, so the handler is
the only thing that runs. Found by the MQTT/Sparkplug P2 live gate — the Request-rebirth
scope is chosen by clicking a FOLDER label, which is the first feature that ever
required clicking a label rather than the ▶ toggle (a button, which always worked). *@
@if (isMultiLeaf)
{
<input type="checkbox" class="form-check-input" checked="@IsLeafSelected(item.Node.NodeId)"
@onchange="@(() => ToggleLeafAsync(item, ancestorPath))" />
<a href="#" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))" @onclick:preventDefault
class="text-decoration-none mono small">@item.Node.DisplayName</a>
<button type="button" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))"
class="btn btn-link btn-sm p-0 text-decoration-none mono small text-start">@item.Node.DisplayName</button>
}
else
{
<a href="#" @onclick="@(() => SelectAsync(item, ancestorPath))" @onclick:preventDefault
class="text-decoration-none mono small">@item.Node.DisplayName</a>
<button type="button" @onclick="@(() => SelectAsync(item, ancestorPath))"
class="btn btn-link btn-sm p-0 text-decoration-none mono small text-start">@item.Node.DisplayName</button>
}
@if (item.Node.Kind == BrowseNodeKind.Leaf)
{
@@ -8,8 +8,11 @@
pure MqttDriverFormModel — enums must round-trip by NAME (this repo's systemic driver-enum bug).
The Sparkplug sub-object (groupId / hostId / actAsPrimaryHost / requestRebirthOnGap /
birthObservationWindowSeconds) is P2 (Task 21+): the placeholder below mirrors how MqttTagConfigEditor
stubs its Sparkplug branch, and any existing sparkplug keys are preserved untouched. *@
birthObservationWindowSeconds) is authored by the Mode == SparkplugB branch below. It is MERGED over
whatever the inbound blob had rather than replacing it, and in Plain mode it is not touched at all —
see MqttDriverFormModel.ToJson. Until the P2 live gate this branch was a "not implemented yet"
placeholder, which left the group id — the driver's entire subscription filter — unauthorable from
the UI after Sparkplug ingest had shipped. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.Mqtt
@@ -171,7 +174,7 @@
<option value="@v">@v</option>
}
</InputSelect>
<div class="form-text">Plain = topic-bound tags. Sparkplug B is not implemented yet.</div>
<div class="form-text">Plain = topic-bound tags. Sparkplug B = birth-described metrics under one group.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-max-payload">Max payload bytes</label>
@@ -201,12 +204,66 @@
}
else
{
@* Sparkplug B. The group id is the ONE mandatory field: it is the driver's entire
subscription filter (spBv1.0/{GroupId}/#), so a blank one leaves the driver connected,
Healthy, and ingesting nothing. Validate() surfaces that inline; like every other knob
on this form it is ADVISORY — DriverConfigModal saves regardless — which is why the
model clamps on serialize instead of relying on the operator reading the notice. *@
<div class="col-md-3">
<label class="form-label" for="mqtt-spb-group">Group ID</label>
<InputText id="mqtt-spb-group" @bind-Value="_form.SparkplugGroupId" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="Plant1" />
<div class="form-text">
Subscribes <code class="mono">spBv1.0/@(string.IsNullOrWhiteSpace(_form.SparkplugGroupId) ? "{GroupId}" : _form.SparkplugGroupId)/#</code>.
No <code>/</code>, <code>+</code>, or <code>#</code>.
</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-spb-window">Birth observation window (seconds)</label>
<InputNumber id="mqtt-spb-window" @bind-Value="_form.SparkplugBirthObservationWindowSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 15 s. How long browse/discovery collects births before calling the metric set stable.</div>
</div>
<div class="col-md-3">
<label class="form-label" for="mqtt-spb-host">Host ID (optional)</label>
<InputText id="mqtt-spb-host" @bind-Value="_form.SparkplugHostId" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="scada-primary" />
<div class="form-text">Sparkplug Host Application identity. Receive-only in this version.</div>
</div>
<div class="col-md-3 d-flex flex-column justify-content-center">
<div class="form-check form-switch">
<InputCheckbox id="mqtt-spb-rebirth" @bind-Value="_form.SparkplugRequestRebirthOnGap" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="mqtt-spb-rebirth">Request rebirth on sequence gap</label>
</div>
<div class="form-text mb-2">A detected seq gap asks the edge node to re-announce (NCMD), rather than running on stale metric state.</div>
<div class="form-check form-switch">
<InputCheckbox id="mqtt-spb-primary" @bind-Value="_form.SparkplugActAsPrimaryHost" @bind-Value:after="EmitAsync" class="form-check-input" />
<label class="form-check-label" for="mqtt-spb-primary">
Act as Primary Host
<span class="badge text-bg-warning">not implemented</span>
</label>
</div>
</div>
@if (_form.SparkplugActAsPrimaryHost)
{
@* Shown rather than the flag being hidden: an operator who needs a primary host must
learn it is absent HERE, not from a plant that never sees a STATE message. The
driver logs the same warning at startup so the two cannot drift. *@
<div class="col-12">
<div class="alert alert-warning py-2 px-3 mb-0 small" role="alert">
<strong>Primary-host STATE publishing is not implemented.</strong> This driver is
receive-only: it will not publish <code class="mono">spBv1.0/STATE/@(string.IsNullOrWhiteSpace(_form.SparkplugHostId) ? "{HostId}" : _form.SparkplugHostId)</code>,
so edge nodes will not treat it as their primary host. The driver logs a warning at
startup rather than being silently inert. Leave this off unless you are staging
configuration ahead of that feature.
</div>
</div>
}
<div class="col-12">
<div class="alert alert-info py-2 px-3 mb-0 small" role="alert">
Sparkplug B settings (group id, host id, primary-host role, rebirth-on-gap, birth
observation window) are not available yet — they land with Sparkplug ingest. Switch back
to <strong>Plain</strong> to author a topic-bound driver. Any existing Sparkplug keys on
this driver are preserved untouched.
Sparkplug tags bind by the <code class="mono">group / edge node / device / metric</code>
tuple, not by topic — author them with <strong>Browse device…</strong> on a device under
this driver, which builds its tree from observed birth certificates.
<strong>MQTT tags are read-only</strong> (write-through is not implemented).
</div>
</div>
}
@@ -46,11 +46,21 @@ public sealed class MqttDriverFormModel
private const string RawTagsKey = nameof(MqttDriverOptions.RawTags);
/// <summary>
/// The <see cref="MqttDriverOptions"/> property name for the P2 Sparkplug sub-object; never
/// authored here and preserved verbatim from the inbound blob.
/// The <see cref="MqttDriverOptions"/> property name for the Sparkplug sub-object. Authored here
/// in <see cref="MqttMode.SparkplugB"/> mode and otherwise preserved verbatim from the inbound
/// blob — see <see cref="ToJson"/> for why the mode decides which of the two happens.
/// </summary>
private const string SparkplugKey = nameof(MqttDriverOptions.Sparkplug);
/// <summary>
/// Characters that cannot appear in a Sparkplug group id. It is a literal MQTT topic segment
/// inside the driver's one subscription filter <c>spBv1.0/{GroupId}/#</c>, so a <c>/</c> would
/// silently widen the filter and a <c>+</c>/<c>#</c> is not even legal mid-segment. Mirrors
/// <c>MqttTagConfigModel</c>'s identical rule on the tag side, so the driver and the tags bound
/// to it cannot disagree about what a group id may be.
/// </summary>
private static readonly char[] SparkplugGroupIdIllegalChars = ['/', '+', '#'];
// --- Broker connection ----------------------------------------------------------------------
/// <summary>Broker hostname or IP address.</summary>
@@ -122,9 +132,37 @@ public sealed class MqttDriverFormModel
/// <summary>Plain mode: default subscription QoS applied when a tag's own <c>qos</c> is unset.</summary>
public int DefaultQos { get; set; } = 1;
// --- Sparkplug B ----------------------------------------------------------------------------
/// <summary>
/// The inbound blob's keys, retained so the P2 <c>Sparkplug</c> sub-object and any key a newer
/// driver adds survive a load→save.
/// Sparkplug mode: the group id. <b>Required</b> — it is the driver's entire subscription scope
/// (<c>spBv1.0/{GroupId}/#</c>), so a blank one subscribes to nothing and the driver ingests
/// nothing while still reporting a healthy broker connection.
/// </summary>
public string SparkplugGroupId { get; set; } = "";
/// <summary>
/// Sparkplug mode: the Host Application identity. Optional, and <b>receive-only</b> in this
/// version — see <see cref="SparkplugActAsPrimaryHost"/>.
/// </summary>
public string SparkplugHostId { get; set; } = "";
/// <summary>
/// Sparkplug mode: claim the Primary Host Application role. <b>Not implemented</b> — STATE
/// publishing is out of scope, and the driver logs a warning rather than being silently inert
/// when this is set. Surfaced (rather than hidden) so an operator who needs it learns that from
/// the form instead of from a quiet plant.
/// </summary>
public bool SparkplugActAsPrimaryHost { get; set; }
/// <summary>Sparkplug mode: answer a detected sequence-number gap with a rebirth request (NCMD).</summary>
public bool SparkplugRequestRebirthOnGap { get; set; } = true;
/// <summary>Sparkplug mode: how long browse/discovery collects births before calling the set stable.</summary>
public int SparkplugBirthObservationWindowSeconds { get; set; } = 15;
/// <summary>
/// The inbound blob's keys, retained so any key a newer driver adds survives a load→save.
/// </summary>
private JsonObject _bag = new();
@@ -172,6 +210,16 @@ public sealed class MqttDriverFormModel
MaxPayloadBytes = o.MaxPayloadBytes,
TopicPrefix = o.Plain?.TopicPrefix ?? "",
DefaultQos = o.Plain?.DefaultQos ?? new MqttPlainOptions().DefaultQos,
SparkplugGroupId = o.Sparkplug?.GroupId ?? "",
SparkplugHostId = o.Sparkplug?.HostId ?? "",
SparkplugActAsPrimaryHost = o.Sparkplug?.ActAsPrimaryHost ?? false,
// Defaulted from the record, not from a literal, so the form shows the driver's own default
// for a blob that has no Sparkplug sub-object at all.
SparkplugRequestRebirthOnGap =
o.Sparkplug?.RequestRebirthOnGap ?? new MqttSparkplugOptions().RequestRebirthOnGap,
SparkplugBirthObservationWindowSeconds =
o.Sparkplug?.BirthObservationWindowSeconds
?? new MqttSparkplugOptions().BirthObservationWindowSeconds,
_bag = bag,
};
}
@@ -181,8 +229,9 @@ public sealed class MqttDriverFormModel
/// range <see cref="MqttDriverOptions"/> declares, so an operator who ignores
/// <see cref="Validate"/> and saves anyway still cannot persist a driver-bricking blob — a
/// <c>connectTimeoutSeconds: 0</c> is exactly the operator-authorable brick this repo has hit
/// before. <see cref="MqttDriverOptions.Sparkplug"/> stays <c>null</c> and
/// <see cref="MqttDriverOptions.RawTags"/> stays empty; both are handled by <see cref="ToJson"/>.
/// before. <see cref="MqttDriverOptions.RawTags"/> stays empty (the deploy artifact owns it) and
/// <see cref="MqttDriverOptions.Sparkplug"/> is emitted only in
/// <see cref="MqttMode.SparkplugB"/> mode; both are finished by <see cref="ToJson"/>.
/// </summary>
/// <returns>The clamped, driver-legal options record.</returns>
public MqttDriverOptions ToOptions() => new()
@@ -208,23 +257,45 @@ public sealed class MqttDriverFormModel
TopicPrefix = TopicPrefix.Trim(),
DefaultQos = Math.Clamp(DefaultQos, 0, 2),
},
// Null in Plain mode so ToJson leaves whatever the inbound blob had; see its remarks.
Sparkplug = Mode == MqttMode.SparkplugB
? new MqttSparkplugOptions
{
GroupId = SparkplugGroupId.Trim(),
HostId = SparkplugHostId.Trim(),
ActAsPrimaryHost = SparkplugActAsPrimaryHost,
RequestRebirthOnGap = SparkplugRequestRebirthOnGap,
BirthObservationWindowSeconds = Math.Max(1, SparkplugBirthObservationWindowSeconds),
}
: null,
};
/// <summary>
/// Serialises the authored fields over the preserved key bag and returns the <c>DriverConfig</c>
/// JSON. Keys are PascalCase and enums are names, because the serialisation runs through
/// <see cref="MqttJson.Options"/> — see the type remarks. <c>Sparkplug</c> is left exactly as the
/// inbound blob had it (P2 authors it) and <c>RawTags</c> is removed (the deploy artifact owns it).
/// <see cref="MqttJson.Options"/> — see the type remarks. <c>RawTags</c> is removed (the deploy
/// artifact owns it).
/// </summary>
/// <remarks>
/// <b><c>Sparkplug</c> is merged, never replaced, and only in Sparkplug mode.</b> In
/// <see cref="MqttMode.Plain"/> the sub-object is left exactly as the inbound blob had it, so an
/// operator who flips a Sparkplug driver to Plain to look at it and flips back does not lose the
/// group id. In <see cref="MqttMode.SparkplugB"/> the five fields this form owns are written
/// <i>over</i> the existing sub-object rather than replacing it, so a key a newer driver adds
/// inside <c>Sparkplug</c> survives an older AdminUI — the same preserve-what-you-do-not-author
/// discipline the top-level bag applies.
/// </remarks>
/// <returns>The serialised <c>DriverConfig</c> JSON string.</returns>
public string ToJson()
{
var typed = JsonSerializer.SerializeToNode(ToOptions(), MqttJson.Options)!.AsObject();
// Never let this form's (always-null / always-empty) placeholders overwrite what it does not own.
typed.Remove(SparkplugKey);
// Never let this form's always-empty placeholder overwrite what it does not own.
typed.Remove(RawTagsKey);
// Pulled out of the flat copy loop below: unlike every other key, this one merges.
var authoredSparkplug = TakeIgnoringCase(typed, SparkplugKey) as JsonObject;
foreach (var (key, value) in typed.ToList())
{
// Case-insensitive replace: MqttJson.Options binds a hand-edited camelCase blob happily, so
@@ -233,6 +304,24 @@ public sealed class MqttDriverFormModel
_bag[key] = value?.DeepClone();
}
if (authoredSparkplug is not null)
{
// Merge over whatever was already there, under the key name the bag already uses so a
// camelCase hand-edited blob does not end up with both "sparkplug" and "Sparkplug".
var existingKey = FindIgnoringCase(_bag, SparkplugKey) ?? SparkplugKey;
if (_bag[existingKey] is not JsonObject target)
{
target = new JsonObject();
_bag[existingKey] = target;
}
foreach (var (key, value) in authoredSparkplug.ToList())
{
RemoveIgnoringCase(target, key);
target[key] = value?.DeepClone();
}
}
RemoveIgnoringCase(_bag, RawTagsKey);
return TagConfigJson.Serialize(_bag);
}
@@ -262,6 +351,28 @@ public sealed class MqttDriverFormModel
}
if (MaxPayloadBytes < 1) { return "Max payload bytes must be at least 1."; }
if (DefaultQos is < 0 or > 2) { return "Default QoS must be 0, 1 or 2."; }
if (Mode == MqttMode.SparkplugB)
{
var groupId = SparkplugGroupId.Trim();
if (groupId.Length == 0)
{
return "A Sparkplug group ID is required — it is the driver's whole subscription scope "
+ "(spBv1.0/{GroupId}/#), so a blank one ingests nothing.";
}
if (groupId.IndexOfAny(SparkplugGroupIdIllegalChars) >= 0)
{
return $"Sparkplug group ID '{groupId}' contains a character ('/', '+', or '#') that "
+ "cannot appear in an MQTT topic segment.";
}
if (SparkplugBirthObservationWindowSeconds < 1)
{
return "Birth observation window must be at least 1 second.";
}
}
return null;
}
@@ -284,6 +395,29 @@ public sealed class MqttDriverFormModel
}
}
/// <summary>The actual key in <paramref name="o"/> matching <paramref name="name"/> case-insensitively.</summary>
/// <param name="o">The object to search.</param>
/// <param name="name">The key name to look for.</param>
/// <returns>The matching key as it is spelled in <paramref name="o"/>, or <c>null</c>.</returns>
private static string? FindIgnoringCase(JsonObject o, string name)
=> o.Select(p => p.Key)
.FirstOrDefault(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase));
/// <summary>Removes and returns the value of a case-insensitively matched key.</summary>
/// <param name="o">The object to mutate.</param>
/// <param name="name">The key name to take.</param>
/// <returns>The detached value, or <c>null</c> when the key was absent (or its value was null).</returns>
private static JsonNode? TakeIgnoringCase(JsonObject o, string name)
{
if (FindIgnoringCase(o, name) is not { } key) { return null; }
var value = o[key];
o.Remove(key);
// Detached before return: a node still parented to `typed` cannot be re-parented into the bag.
return value?.DeepClone();
}
/// <summary>Binds the blob through the shared options; <c>null</c> on blank/malformed input.</summary>
/// <param name="json">The raw <c>DriverConfig</c> JSON.</param>
/// <returns>The bound options, or <c>null</c>.</returns>
@@ -70,7 +70,22 @@
else
{
<div class="d-flex align-items-center justify-content-between mb-2">
<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()
{
@@ -184,13 +184,121 @@ public sealed class MqttDriverFormModelTests
var json = MqttDriverFormModel.FromJson(inbound).ToJson();
var o = Parse(json);
// Sparkplug is P2 (Task 21+) — this form never authors it, and must never drop it.
// The blob has no Mode key, so it is Plain — and in Plain mode this form does not author the
// Sparkplug sub-object at all, so it must come back byte-identical.
o["sparkplug"].ShouldNotBeNull();
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("G1");
o["sparkplug"]!["ActAsPrimaryHost"]!.GetValue<bool>().ShouldBeTrue();
o["aFutureKey"]!.GetValue<int>().ShouldBe(42);
}
// --- Sparkplug B authoring -------------------------------------------------------------------
//
// These pin the defect the P2 live gate found: MqttDriverForm shipped its Sparkplug branch as a
// "not implemented yet" placeholder, so once Sparkplug ingest landed the group id — the driver's
// ENTIRE subscription filter, spBv1.0/{GroupId}/# — could not be authored from the AdminUI at all.
// The driver deployed connected, Healthy, and ingesting nothing.
[Fact]
public void Sparkplug_mode_round_trips_the_group_id_through_a_load_save_load()
{
var authored = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
authored.Mode = MqttMode.SparkplugB;
authored.SparkplugGroupId = "OtOpcUaSim";
authored.SparkplugRequestRebirthOnGap = true;
var reloaded = MqttDriverFormModel.FromJson(authored.ToJson());
reloaded.Mode.ShouldBe(MqttMode.SparkplugB);
reloaded.SparkplugGroupId.ShouldBe("OtOpcUaSim");
reloaded.SparkplugRequestRebirthOnGap.ShouldBeTrue();
}
[Fact]
public void Sparkplug_mode_emits_a_group_id_the_driver_options_actually_bind()
{
// The whole point of the round-trip: what this form writes must deserialize back through the
// SAME shared MqttJson.Options the runtime factory uses, or the driver sees a null group.
var authored = MqttDriverFormModel.FromJson(null);
authored.Mode = MqttMode.SparkplugB;
authored.SparkplugGroupId = "OtOpcUaSim";
var options = JsonSerializer.Deserialize<MqttDriverOptions>(authored.ToJson(), MqttJson.Options)!;
options.Mode.ShouldBe(MqttMode.SparkplugB);
options.Sparkplug.ShouldNotBeNull();
options.Sparkplug!.GroupId.ShouldBe("OtOpcUaSim");
}
[Fact]
public void Sparkplug_authoring_merges_over_the_subobject_rather_than_replacing_it()
{
// Same preserve-what-you-do-not-author discipline the top-level bag applies: a key a newer
// driver adds INSIDE the sub-object must survive an older AdminUI's save.
const string inbound = """
{"Host":"h","Mode":"SparkplugB","sparkplug":{"GroupId":"Old","aFutureSpbKey":7}}
""";
var model = MqttDriverFormModel.FromJson(inbound);
model.SparkplugGroupId = "New";
var o = Parse(model.ToJson());
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("New");
o["sparkplug"]!["aFutureSpbKey"]!.GetValue<int>().ShouldBe(7);
}
[Fact]
public void A_camelCase_sparkplug_key_is_merged_not_duplicated()
{
const string inbound = """{"Host":"h","Mode":"SparkplugB","sparkplug":{"GroupId":"Old"}}""";
var model = MqttDriverFormModel.FromJson(inbound);
model.SparkplugGroupId = "New";
var o = Parse(model.ToJson());
o.Count(p => string.Equals(p.Key, "Sparkplug", StringComparison.OrdinalIgnoreCase)).ShouldBe(1);
o["sparkplug"]!["GroupId"]!.GetValue<string>().ShouldBe("New");
}
[Fact]
public void Plain_mode_never_writes_a_sparkplug_subobject_onto_a_blob_that_had_none()
{
var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
model.SparkplugGroupId = "TypedThenSwitchedAway"; // Mode is still Plain.
HasKeyIgnoringCase(Parse(model.ToJson()), "Sparkplug").ShouldBeFalse();
}
[Fact]
public void A_blank_group_id_is_refused_in_sparkplug_mode_only()
{
var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
// Plain does not care — it binds by topic.
model.Validate().ShouldBeNull();
model.Mode = MqttMode.SparkplugB;
model.Validate().ShouldNotBeNull();
model.Validate()!.ShouldContain("Sparkplug group ID is required");
}
[Theory]
[InlineData("Plant/1")]
[InlineData("Plant+1")]
[InlineData("Plant#1")]
public void A_group_id_carrying_a_topic_metacharacter_is_refused(string groupId)
{
// It is a literal segment inside spBv1.0/{GroupId}/#, so '/' silently widens the filter and
// '+'/'#' are not even legal mid-segment. Mirrors MqttTagConfigModel's tag-side rule.
var model = MqttDriverFormModel.FromJson("""{"Host":"h"}""");
model.Mode = MqttMode.SparkplugB;
model.SparkplugGroupId = groupId;
model.Validate()!.ShouldContain("cannot appear in an MQTT topic segment");
}
[Fact]
public void A_camelCase_inbound_key_is_replaced_not_duplicated()
{