diff --git a/CLAUDE.md b/CLAUDE.md index 7cf8f8cb..8946cba0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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**. diff --git a/docker-dev/docker-compose.mqtt.yml b/docker-dev/docker-compose.mqtt.yml new file mode 100644 index 00000000..9fc9d51c --- /dev/null +++ b/docker-dev/docker-compose.mqtt.yml @@ -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" diff --git a/docs/drivers/Mqtt-Test-Fixture.md b/docs/drivers/Mqtt-Test-Fixture.md index 622758b2..c5847b06 100644 --- a/docs/drivers/Mqtt-Test-Fixture.md +++ b/docs/drivers/Mqtt-Test-Fixture.md @@ -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 diff --git a/docs/drivers/Mqtt.md b/docs/drivers/Mqtt.md index f0afcb8f..dc3806a9 100644 --- a/docs/drivers/Mqtt.md +++ b/docs/drivers/Mqtt.md @@ -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 ``. `blazor.web.js`'s enhanced-navigation click interceptor +> resolves a bare `#` against ``, 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 ` } else { - @item.Node.DisplayName + } @if (item.Node.Kind == BrowseNodeKind.Leaf) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverForm.razor index 8349837c..cb18f6bc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverForm.razor @@ -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 @@ } -
Plain = topic-bound tags. Sparkplug B is not implemented yet.
+
Plain = topic-bound tags. Sparkplug B = birth-described metrics under one group.
@@ -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. *@ +
+ + +
+ Subscribes spBv1.0/@(string.IsNullOrWhiteSpace(_form.SparkplugGroupId) ? "{GroupId}" : _form.SparkplugGroupId)/#. + No /, +, or #. +
+
+
+ + +
Default 15 s. How long browse/discovery collects births before calling the metric set stable.
+
+
+ + +
Sparkplug Host Application identity. Receive-only in this version.
+
+
+
+ + +
+
A detected seq gap asks the edge node to re-announce (NCMD), rather than running on stale metric state.
+
+ + +
+
+ + @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. *@ +
+ +
+ } +
} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverFormModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverFormModel.cs index 820ce797..a003bc63 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverFormModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MqttDriverFormModel.cs @@ -46,11 +46,21 @@ public sealed class MqttDriverFormModel private const string RawTagsKey = nameof(MqttDriverOptions.RawTags); /// - /// The property name for the P2 Sparkplug sub-object; never - /// authored here and preserved verbatim from the inbound blob. + /// The property name for the Sparkplug sub-object. Authored here + /// in mode and otherwise preserved verbatim from the inbound + /// blob — see for why the mode decides which of the two happens. /// private const string SparkplugKey = nameof(MqttDriverOptions.Sparkplug); + /// + /// Characters that cannot appear in a Sparkplug group id. It is a literal MQTT topic segment + /// inside the driver's one subscription filter spBv1.0/{GroupId}/#, so a / would + /// silently widen the filter and a +/# is not even legal mid-segment. Mirrors + /// MqttTagConfigModel'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. + /// + private static readonly char[] SparkplugGroupIdIllegalChars = ['/', '+', '#']; + // --- Broker connection ---------------------------------------------------------------------- /// Broker hostname or IP address. @@ -122,9 +132,37 @@ public sealed class MqttDriverFormModel /// Plain mode: default subscription QoS applied when a tag's own qos is unset. public int DefaultQos { get; set; } = 1; + // --- Sparkplug B ---------------------------------------------------------------------------- + /// - /// The inbound blob's keys, retained so the P2 Sparkplug sub-object and any key a newer - /// driver adds survive a load→save. + /// Sparkplug mode: the group id. Required — it is the driver's entire subscription scope + /// (spBv1.0/{GroupId}/#), so a blank one subscribes to nothing and the driver ingests + /// nothing while still reporting a healthy broker connection. + /// + public string SparkplugGroupId { get; set; } = ""; + + /// + /// Sparkplug mode: the Host Application identity. Optional, and receive-only in this + /// version — see . + /// + public string SparkplugHostId { get; set; } = ""; + + /// + /// Sparkplug mode: claim the Primary Host Application role. Not implemented — 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. + /// + public bool SparkplugActAsPrimaryHost { get; set; } + + /// Sparkplug mode: answer a detected sequence-number gap with a rebirth request (NCMD). + public bool SparkplugRequestRebirthOnGap { get; set; } = true; + + /// Sparkplug mode: how long browse/discovery collects births before calling the set stable. + public int SparkplugBirthObservationWindowSeconds { get; set; } = 15; + + /// + /// The inbound blob's keys, retained so any key a newer driver adds survives a load→save. /// 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 declares, so an operator who ignores /// and saves anyway still cannot persist a driver-bricking blob — a /// connectTimeoutSeconds: 0 is exactly the operator-authorable brick this repo has hit - /// before. stays null and - /// stays empty; both are handled by . + /// before. stays empty (the deploy artifact owns it) and + /// is emitted only in + /// mode; both are finished by . /// /// The clamped, driver-legal options record. 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, }; /// /// Serialises the authored fields over the preserved key bag and returns the DriverConfig /// JSON. Keys are PascalCase and enums are names, because the serialisation runs through - /// — see the type remarks. Sparkplug is left exactly as the - /// inbound blob had it (P2 authors it) and RawTags is removed (the deploy artifact owns it). + /// — see the type remarks. RawTags is removed (the deploy + /// artifact owns it). /// + /// + /// Sparkplug is merged, never replaced, and only in Sparkplug mode. In + /// 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 the five fields this form owns are written + /// over the existing sub-object rather than replacing it, so a key a newer driver adds + /// inside Sparkplug survives an older AdminUI — the same preserve-what-you-do-not-author + /// discipline the top-level bag applies. + /// /// The serialised DriverConfig JSON string. 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 } } + /// The actual key in matching case-insensitively. + /// The object to search. + /// The key name to look for. + /// The matching key as it is spelled in , or null. + private static string? FindIgnoringCase(JsonObject o, string name) + => o.Select(p => p.Key) + .FirstOrDefault(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)); + + /// Removes and returns the value of a case-insensitively matched key. + /// The object to mutate. + /// The key name to take. + /// The detached value, or null when the key was absent (or its value was null). + 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(); + } + /// Binds the blob through the shared options; null on blank/malformed input. /// The raw DriverConfig JSON. /// The bound options, or null. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor index c0ec5ca4..144a112f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawBrowseModal.razor @@ -70,7 +70,22 @@ else {
- Browser open +
+ Browser open + @* 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. *@ + +
@@ -80,7 +95,7 @@
- @@ -245,6 +260,12 @@ private bool _busy; private List _commitErrors = new(); + /// + /// Bumped to force a fresh DriverBrowseTree against the same session — see the Refresh + /// button's remarks. Seeded from the session so re-opening a modal always starts a new generation. + /// + 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 @@ } } + /// + /// 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. + /// + /// + /// The tag selection is deliberately kept — _selectedIds 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. + /// + private void RefreshTree() + { + _treeGeneration++; + ResetRebirthState(); + } + /// Clears every rebirth-panel field (modal re-open, browser close). private void ResetRebirthState() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttDriverFormModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttDriverFormModelTests.cs index 5c5e1bd3..f360bdb8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttDriverFormModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MqttDriverFormModelTests.cs @@ -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().ShouldBe("G1"); o["sparkplug"]!["ActAsPrimaryHost"]!.GetValue().ShouldBeTrue(); o["aFutureKey"]!.GetValue().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(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().ShouldBe("New"); + o["sparkplug"]!["aFutureSpbKey"]!.GetValue().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().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() {