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:
@@ -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**.
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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 15–26) 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
@@ -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 0–255 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) | 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) | ✅ **P1 done** (Tasks 0–14, live-gated) · 📝 P2 Sparkplug plan ready (Tasks 15–26) | M–L | 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 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
|
||||
@@ -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:** S–M (≈1–1.5 wk with TrakHound, ≈2.5–3 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 0–14, a complete shippable milestone; P2 Sparkplug B = Tasks 15–26). Task 0 is the mandatory MQTTnet-5/net10 + central-pinning validation spike.
|
||||
- **Scope:** P1 plain MQTT (MQTTnet-5 connect/TLS/auth + hand-rolled reconnect, subscribe→
|
||||
- **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, 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 0–14 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 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`, 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:** M–L. **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:** 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 15–26), 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.
|
||||
|
||||
@@ -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` ·
|
||||
|
||||
+14
-4
@@ -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)
|
||||
{
|
||||
|
||||
+64
-7
@@ -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>
|
||||
}
|
||||
|
||||
+144
-10
@@ -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">
|
||||
<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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user