Files
lmxopcua/docs/drivers/Mqtt.md
T
Joseph Doherty 1ea1da8236 refactor(runtime): delete the dead in-place ApplyDelta seam; config change = respawn only (#665)
Since #516 a DriverConfig change is stop + respawn, so DriverSpawnPlan.ToApplyDelta
was always empty and the in-place path had no producer. #663 and #664 were both
latent bugs in it. This deletes it:
- host: ApplyChildDelta, _pendingDelta, HandleApplyResult and HandleDeltaApplied
- plan: DriverSpawnPlan.ToApplyDelta
- actor: the ApplyDelta, ApplyResult and DeltaApplied messages, the Connected
  reinit handler and the mid-connect AdoptConfigDuringInit
- tests: the actor tests that drove the seam

IDriver.ReinitializeAsync and each driver's implementation and unit tests stay.
Comments and docs that cited AdoptConfigDuringInit as the reason a driver must
release prior state at init now cite the IDriver contract and overlapping
retries, which still apply.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 11:23:16 -04:00

677 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MQTT Driver
In-process MQTT **broker-subscribe** driver. It holds one MQTTnet 5 client against one broker,
subscribes to the topics its authored tags name, and forwards each PUBLISH straight to
`ISubscribable.OnDataChange`. Ingest is **push, not poll**; writes go back out as ordinary publishes
(see [Write-through](#write-through)).
**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), P2 (Sparkplug B ingest), write-through
> ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508)) and the Sparkplug primary-host
> claim ([#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511)) are all shipped and
> live-gated against the Mosquitto + Sparkplug-simulator fixture. See
> [Known gaps](#known-gaps) — every entry there carries a tracking issue.
## Project Layout
| Project | Holds |
|---|---|
| `Driver.Mqtt.Contracts` | Config records, enums, `MqttTagDefinition` + the pure `MqttTagDefinitionFactory`, and the one shared `MqttJson.Options`. **Deliberately transport-free** — no MQTTnet reference |
| `Driver.Mqtt` | Runtime: `MqttDriver`, `MqttConnection` (connect + reconnect supervisor), `MqttSubscriptionManager`, `LastValueCache`, `SparkplugIngestor`, `SparkplugPrimaryHost`, factory registration, `MqttDriverProbe` |
| `Driver.Mqtt.Transport` | `MqttClientOptionsFactory` — TLS posture, PEM CA-pin chain validator, credentials, protocol mapping. Connection **policy** only; nothing here connects |
| `Driver.Mqtt.Browser` | `MqttDriverBrowser` + `MqttBrowseSession` — the AdminUI address picker's `#`-observation session |
> **`.Browser` depends on the shared leaves only** — `.Contracts` + `.Transport`, never the runtime
> `.Driver`. It used to reference `.Driver` under a documented exception; #512 removed it via two
> extractions (the client-options builder to `.Transport`, and the Sparkplug protocol layer —
> `SparkplugTopic`, `SparkplugCodec`, `RebirthRequester` — to `.Contracts`, beside the Tahu wire
> schema they encode). `MqttBrowserLayeringTests` guards the graph in both directions.
>
> There must stay **exactly one copy** of the TLS/CA-pin logic. A second copy would not break the
> build — it would silently diverge, browse quietly not honouring a pinned CA while the driver does.
## Capability Surface
```csharp
public sealed class MqttDriver
: IDriver, ITagDiscovery, ISubscribable, IReadable, IWritable, IHostConnectivityProbe, IRediscoverable,
IAsyncDisposable, IDisposable
```
| 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` 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` |
| `IWritable` | `WriteAsync` | Publishes to the tag's **`writeTopic`** (Plain) or as an **NCMD/DCMD** command (Sparkplug). One status per request; a tag with no write destination is refused `BadNotWritable`. See [Write-through](#write-through) |
| `IHostConnectivityProbe` | `GetHostStatuses` | 1-second poll of `MqttConnection.State`; `HostName` is `"{host}:{port}"` |
| `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:** `IPerCallHostResolver`, `IAlarmSource`.
Materialized nodes are `IsAlarm: false`, `IsHistorized: false`. A node is
`SecurityClassification.Operate` when its tag declares a write destination and
`ViewOnly` when it does not — the browse-time answer comes from the same encoder the write path uses,
so a node can never advertise writability it would then refuse.
Driver-type string: **`Mqtt`** (`DriverTypeNames.Mqtt`).
## Configuration
Bound through `MqttJson.Options` — **case-insensitive**, enums by **name**, unknown members skipped.
**The whole connection is authored on the DRIVER** (`DriverConfig`), via `MqttDriverForm` in the `/raw`
driver-config modal. Unlike Modbus / S7 / OPC UA Client, MQTT does **not** use the v3
endpoint→`DeviceConfig` split: an MQTT driver instance holds exactly one broker session, so
`MqttDeviceForm` is informational (the same shape as `GalaxyDeviceForm`) and devices under an MQTT
driver are pure organisational grouping in the RawPath.
> **Why not the device.** `DriverDeviceConfigMerger` merges a device's keys up to the top level *only
> when the driver has exactly one device*. A device-authored broker connection therefore disappears
> from the merged blob the moment a second device is added — a silent outage with nothing to see at
> deploy time. Driver-level authoring is correct for 0, 1 or N devices. A pre-form deployment that put
> the connection on a sole device still works (merge-up wins over the driver's keys) and
> `MqttDeviceForm` flags those keys so the mismatch is visible; move them to the driver.
`DriverDeviceConfigMerger` still merges driver + device and injects `rawTags`.
```jsonc
// DriverConfig — authored by MqttDriverForm. PascalCase keys, enums as names.
{
"Host": "10.100.0.35",
"Port": 8883,
"UseTls": true,
"AllowUntrustedServerCertificate": false,
"CaCertificatePath": null, // pins the chain; null = OS trust store
"Username": "otopcua",
"Password": "", // never commit
"ProtocolVersion": "V500",
"CleanSession": true,
"KeepAliveSeconds": 30,
"ConnectTimeoutSeconds": 15,
"ReconnectMinBackoffSeconds": 1,
"ReconnectMaxBackoffSeconds": 30,
"MaxPayloadBytes": 1048576,
"Mode": "Plain",
"Plain": { "TopicPrefix": "", "DefaultQos": 1 }
}
// DeviceConfig — empty; MQTT has no per-device endpoint.
{}
```
### `Sparkplug` sub-object (`Mode: "SparkplugB"`)
```jsonc
{
"Mode": "SparkplugB",
"Sparkplug": {
"GroupId": "OtOpcUaSim", // REQUIRED — the driver's whole subscription scope
"HostId": "", // Host Application identity — spBv1.0/STATE/{HostId}
"ActAsPrimaryHost": false, // claim the primary host id (Primary-gated; see below)
"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 — the `spBv1.0/STATE/{HostId}` topic. Optional for observation; **required** to claim. Set without `ActAsPrimaryHost`, the driver only observes |
| `ActAsPrimaryHost` | `false` | Claim the Sparkplug Primary Host Application identity. **Primary-gated** — only the node holding the redundancy Primary role claims it. See [Primary host](#primary-host-actasprimaryhost) |
| `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**,
`keepAliveSeconds` **30**, `connectTimeoutSeconds` **15**,
`reconnectMinBackoffSeconds` **1** / `reconnectMaxBackoffSeconds` **30**,
`maxPayloadBytes` **1 MiB**, `allowUntrustedServerCertificate` **false**.
> ⚠️ **Leave `clientId` unset on a redundant pair.** Both nodes of a pair run the driver. MQTT
> requires client ids to be unique per broker, so a *fixed* `clientId` makes the two nodes evict each
> other forever — the broker logs `Client <id> already connected, closing old connection` and both
> nodes reconnect every few seconds while still reporting `Healthy`. Unset (the default) lets MQTTnet
> generate a unique id per connection, which is correct. This was observed live during the P1 gate.
> (The probe and browser already self-uniquify with `-probe-` / `-browse-` suffixes.) `MqttDriverForm`
> defaults the field blank and raises this warning inline the moment a value is typed.
The form validates every knob against the driver's own `[Range]` bounds (port 1–65535, keep-alive /
connect-timeout / backoffs ≥ 1 s, max-backoff ≥ min-backoff, `maxPayloadBytes` ≥ 1, QoS 0–2) and
additionally **clamps** on serialize, so an operator who ignores the inline error still cannot persist
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` |
| `payloadFormat` | `Json` \| `Raw` \| `Scalar` | `Json` | **Strict** — a typo'd value rejects the tag |
| `jsonPath` | string | `"$"` | Absent *or empty* ⇒ document root. `Json` only |
| `dataType` | `DriverDataType` | `String` | **Strict.** `Float64`, not `Double` — see below |
| `qos` | int 0–2 | driver's `defaultQos` | **Strict** — a malformed value rejects rather than silently weakening delivery |
| `retainSeed` | bool | `true` | Seed this tag from the broker's retained message on subscribe |
There is **no `mode` key** — the AdminUI's "Tag shape" selector is UI-only, inferred from the blob and
never serialized.
**`dataType` uses `DriverDataType`, which has `Float64` and has no `Double`.** Authoring `"Double"` is
a strict-enum rejection, not a fallback.
Payload handling: `Json` selects via JSONPath then coerces (miss ⇒ `BadDecodingError`, coerce fail ⇒
`BadTypeMismatch`); `Raw` returns the payload's UTF-8 text verbatim and **ignores `dataType`**;
`Scalar` decodes then coerces. The JSONPath subset is `$`, `$.a.b`, `$['a']`, `$.a[0]` — **no
filters, slices, wildcards or recursive descent**.
### Wildcards
The **runtime accepts** a `+`/`#` topic (it is ambiguous, not unparseable) and the deploy-time
`Inspect` pass **warns**. The **AdminUI editor rejects** it outright — the one place the editor is
deliberately stricter than the driver, because one Tag holds one value and a wildcard would feed it
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. Also how a flattened structured member is addressed: `Motor/Speed`, `Recipe/Target/0` (see [Structured metrics](#structured-metrics-template--dataset--propertyset)) |
| `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"}
// A flattened Template member, and a flattened DataSet cell. `Motor` and `Recipe` are what the wire
// carries; nothing on the wire is ever called by these names.
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Motor/Stator/Temp"}
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Recipe/Target/1"}
```
## 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.
- **Structured metrics flatten to sibling tags** — `Template` and `DataSet`; see
[Structured metrics](#structured-metrics-template--dataset--propertyset).
- **Unsupported datatypes are skipped with a warning, never guessed**: `PropertySetList`, `Unknown`,
extension values, and any `Template`/`DataSet` that cannot be flattened honestly. `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.
> ⚠️ **The signal is an operator prompt, not a rebuild.** `DriverInstanceActor` subscribes and the
> raise rides the driver-health snapshot to the AdminUI `/hosts` page as a **re-browse chip** — but the
> served address space still comes from the deploy artifact, so **a DBIRTH introducing a new metric does
> not change the OPC UA tree**. Re-browse the device and commit. That is deliberate: v3 authors raw tags
> through the `/raw` browse-commit flow, and grafting nodes at runtime would materialise ones nobody
> approved and no deployment artifact records.
## Structured metrics (`Template` / `DataSet` / `PropertySet`)
Sparkplug metrics are not all scalars. `SparkplugFlattener`
([#515](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/515)) turns the structured ones into
addressable tags:
| Metric kind | Mapping | Example |
|---|---|---|
| `Template` (UDT instance) | members become **sibling metrics**, `/`-joined, recursively | `Motor` → `Motor/Speed`, `Motor/Stator/Temp` |
| `DataSet` | **one metric per cell**, `{column}/{rowIndex}` | `Recipe` → `Recipe/Step/0`, `Recipe/Target/1` |
| `PropertySet` (a metric's `properties`) | node **metadata**, never tags | `engUnit`, `Range/Low` |
Flattened members are authored exactly like any other Sparkplug metric — the composed name goes in
`metricName`. The browse picker offers them directly (the structured **parent** is deliberately *not*
offered: it has no value and no servable datatype, so a tag committed against it could never read).
**Why flattening rather than OPC UA Structures.** Owner decision, 2026-07-31. Structures are truer to
Sparkplug's model but much harder to consume — flattened scalars are readable by any OPC UA client
with no type dictionary, and the result mirrors how the `/raw` tree already works.
### Things worth knowing before you touch this
- **The row index is always present, including on row 0.** `Recipe/Target/0`, never `Recipe/Target`.
Emitting a bare column name for a single-row table and an indexed one otherwise would make a tag's
name depend on how many rows the plant published — so a table growing from one row to two would
rename every tag already authored against it.
- **A DATA metric carries the parent's alias and no name**, so member names cannot be composed until
the alias table has resolved the parent. Flattening at decode time alone is not enough; the
expansion happens in `SparkplugIngestor.PublishStructuredMembers`, after resolution.
- **Member datatype falls back to the birth's.** A DDATA template may omit its members' datatypes.
Guessing from the CLR type of the wire value would read a signed `Int32` of −42 out of its unsigned
field as 4294967254 — plausible, `Good`, and wrong.
- **Skip-and-warn survives, and refusal is whole.** A template **definition** (`is_definition`), an
unnamed member, an extension value, a column/type count mismatch, a short row, or a payload past
`MaxDepth` (5) / `MaxMembers` (512) refuses the **entire** metric rather than publishing part of it.
A silently half-flattened UDT looks exactly like a complete one in the picker.
- **Member paths contain `/`**, which `RawBrowseCommitMapper.ToRawSegment`
([#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509)) already handles: the slashed name
travels intact as `metricName` while the derived tag **Name** is coerced to a legal RawPath segment,
with a collision guard for two members that would sanitise onto one name.
- **The rediscovery signature tracks members, not the parent.** A UDT gaining or losing a member leaves
the parent metric's name identical, so a gate built on raw wire names would never move.
`PropertySet` is projected as metadata rather than as sibling tags because it *describes* a metric
(units, ranges, quality hints) — surfacing it as tags would invent plant signals out of documentation.
`PropertySetList` and property extension values are dropped rather than stringified.
## Primary host (`actAsPrimaryHost`)
A Sparkplug **Primary Host Application** is the thing edge nodes watch to decide whether anyone is
listening — many stop publishing while their host is offline. Claiming it is a three-part contract,
and this driver ships all three or none of it
([#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511)):
1. a **retained** `{"online":true,"timestamp":…}` on `spBv1.0/STATE/{hostId}`, QoS 1, published after CONNACK;
2. a matching **retained `OFFLINE` armed as the MQTT Last Will**, so a crash still reports the truth;
3. an explicit `OFFLINE` on a clean stand-down, so edge nodes do not wait out a keep-alive timeout.
A claim the broker keeps refusing backs off (STAB-27): 2 s, 4 s, 8 s … capped at 30 s between attempts, one
Warning per attempt, reset by a successful claim or a stand-down. The role is still sampled every second.
Set `Sparkplug.HostId` and `Sparkplug.ActAsPrimaryHost: true`. A blank host id is **refused** with a
warning — the driver does not publish to `spBv1.0/STATE/`, which is a legal topic reaching nobody.
### The claim is a whole connection, not a message
A Will can only be registered in the **CONNECT packet**. It cannot be added to a live session, changed,
or withdrawn. So the claim gets its **own** broker connection (client id `{clientId}-spbhost`, forced
clean-session) whose lifetime *is* the claim: opened on gaining Primary, closed on losing it. Every leg
then follows from the connection's lifecycle rather than from bookkeeping that could drift.
The ingest session is untouched by a role change — no dropped subscriptions, no lost birth cache, and
no data gap at the Secondary→Primary transition, which is the moment a plant most needs values flowing.
### Primary-gated — and why the obvious alternatives are wrong
Both nodes of a redundant pair subscribe independently (MQTT fan-out is per-connection, and the Primary
gate applies downstream of ingest), so an ungated claim means both claim the same host id. The sharp
failure is not the two fighting over the retained `STATE`. It is that the **Secondary's** death fires
*its* Will, publishing `OFFLINE` while the Primary is perfectly healthy — every edge node in the group
stops publishing, and redundancy has caused the outage the mechanism exists to prevent.
Registering the Will only on whichever node is Primary *at connect time* fails the other way: a node
that later gains Primary would publish `ONLINE` with no Will behind it, and a crash would leave a
retained `ONLINE` asserting forever that a dead host is alive.
Hence: bind the claim to a connection, and rebuild the connection on role change.
| Node state | STATE connection | Retained `STATE` |
|---|---|---|
| Primary | open, Will armed | `ONLINE` |
| Secondary / Detached | **never opened** | — (whatever the Primary published) |
| Role unknown, driver peer present | not opened (fail closed) | — |
| Role unknown, no driver peer (single node) | open | `ONLINE` |
The verdict comes from `PrimaryGatePolicy.ShouldServiceAsPrimary` via `IDriverPrimaryRoleSource`
(`Core.Abstractions`), published by `DriverHostActor` at host start, on every cluster membership change and on
every redundancy snapshot, and **sampled on a 1-second timer** rather than evented — a process-singleton
raising events at per-instance drivers outlives every driver it holds a reference to.
> ⚠️ **"Driver peer present" is this node's own cluster view.** Before the node has joined, it sees no
> members, which is the single-node row: a node cold-starting into a pair whose peer is already Primary
> can open the claim until its join completes (seconds; up to the bootstrap guard's 25 s partner probe),
> so both nodes may briefly hold it (review R6 MINOR-2).
> **Accepted trade: a brief edge-visible `OFFLINE` during failover**, while the old Primary stands down
> and the new one connects and republishes. That is a genuine state transition — for that window there
> really is no host — not a glitch worth hiding.
### Operational notes
- **At most one primary host per host id per broker.** Two separate OtOpcUa deployments pointing at one
broker with the same `hostId` will fight, and nothing in either can detect the other. The gate covers
a redundant *pair*, not two independent deployments.
- **A reconnect republishes the birth.** A broker restart drops retained messages; without the
republish the host would be connected and believed offline by everyone, silently.
- **A failed claim never faults the driver.** A broker whose ACL denies `spBv1.0/STATE/#` costs a
warning and a plant-visible missing host, not every tag's value. Ingest is the data plane; the claim
is an assertion to other applications.
- **The driver still observes its own `STATE`.** The ingestor subscribes the configured host id
regardless, so a claiming node sees its own retained `ONLINE` come back. Nothing gates on it, so the
echo costs one log line.
## Connection + Failure Semantics
`MqttConnectionState`: `Disconnected → Connected → Reconnecting → Faulted → Disposed`.
A broker that is merely down stays **`Reconnecting` indefinitely** — unreachable is never a fault.
Backoff is exponential between the two `reconnectBackoff` bounds.
**MQTTnet 5 does not throw on a rejected CONNACK** — it returns the code and leaves the client
disconnected. Left unhandled, a wrong password produced a `Healthy` driver that never received a
value. `MqttConnection` therefore raises `MqttConnectRejectedException`:
- **Unrecoverable ⇒ `Faulted`, supervisor stops** (retrying cannot help): `MalformedPacket`,
`ProtocolError`, `UnsupportedProtocolVersion`, `ClientIdentifierNotValid`, `BadUserNameOrPassword`,
`NotAuthorized`, `Banned`, `BadAuthenticationMethod`, `TopicNameInvalid`, `PacketTooLarge`,
`PayloadFormatInvalid`, `RetainNotSupported`, `QoSNotSupported`, `ServerMoved`.
- **Everything else is transient** and keeps retrying — including `UnspecifiedError` and any future
code. (`ServerMoved` is permanent, `UseAnotherServer` is temporary — per the MQTT 5 spec.)
A reconnect that completes without re-subscribing would be a healthy-looking connection that receives
nothing forever, so `OnReconnectedAsync` **throws** when zero filters are granted, tearing the session
down rather than swallowing it. A SUBACK failure degrades just the affected RawPaths to
`BadCommunicationError`.
There are **two** kinds of reconnect and only one of them fires that callback. A drop the connection's
own supervisor recovers keeps the same `MqttConnection`, so `Reconnected` → `OnReconnectedAsync`
re-establishes every live filter. A **session rebuild** — `ShutdownAsync` + `InitializeAsync` (the #527
comms-loss watchdog's retry path, and a `ReinitializeAsync` that changes the endpoint or credentials) — builds
a *new* connection and re-attaches the *same* `MqttSubscriptionManager`, and the re-subscribe then comes
from the host (`DriverInstanceActor.ResubscribeDesired`) rather than from the callback. `AttachTo`
therefore **forgets the filters the previous session had established**: the dedupe set is a claim about
one session, and carrying it across made the host's re-subscribe resolve an empty pending set, send no
SUBSCRIBE at all, and leave the driver Connected/Healthy and permanently deaf. Sparkplug's
`SparkplugIngestor.AttachTo` resets its own session state for the same reason.
## Quality on connection loss and re-subscribe
The quality contract this implements is stated once, in
[`docs/DriverLifecycle.md` §"The quality contract"](../DriverLifecycle.md#the-quality-contract).
Two edge behaviours make MQTT — a push-model driver — honour it. A poll driver gets both free from
`PollGroupEngine` (a fresh subscription's first poll runs with `forceRaise: true`); MQTT had neither,
which is archreview 05/STAB-18.
- **Connection loss stales, keeping the value.** On the `Connected → Reconnecting` edge
`MqttConnection.ConnectionLost` fires, and both ingest paths mark every **already-observed**
reference `BadNoCommunication` with its **last value retained** (server-side; a client reads the Bad status with a null value) and publish the change. A
never-observed reference (still `BadWaitingForInitialData`) and an already-Bad one are left alone.
The event does **not** fire for a failed *initial* connect or during dispose — neither is an outage.
- **Every (re)subscribe pushes the cached snapshots.** `SubscribeAsync` republishes each observed
reference's cached snapshot onto the new handle before any broker traffic. This is what refills a
node an address-space rebuild reseeded `BadWaitingForInitialData` on a quiet topic — a rebuild
triggers no retained replay and, in Sparkplug, no rebirth.
**Why the stale is what makes recovery work.** The plain path suppresses a message whose value *and*
status equal the driver-owned cache, and that cache survives reconnects and redeploys. So the retained
message a broker replays on reconnect — routinely the identical payload — was suppressed, and every
node the host had latched Bad stayed Bad until the plant value happened to change. Staling the status
first makes the identical replay a change. (A regression test for this must replay the **identical**
payload; a different value defeats the suppression on its own and proves nothing.)
**Two "not communicating" codes, deliberately.** `BadNoCommunication` (0x80310000) means *the link is
down* — the connection-loss stale, and what the host's comms-loss fan-out writes onto the value nodes.
`BadCommunicationError` (0x80050000) keeps its narrower Sparkplug meaning: the **source announced its
own death** (NDEATH/DDEATH), or a birth voided the scope feeding the tag. A refused SUBSCRIBE filter
also degrades to `BadCommunicationError`.
**Operator-visible change:** an MQTT tag now goes Bad when the broker session drops, rather than
holding its last value at Good until the host's 90 s comms-loss watchdog notices — which, since
MQTTnet usually reconnects well inside that window, in practice meant never.
## Write-through
`IWritable` landed with [#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508). Before it,
every MQTT node materialized read-only regardless of what the operator authored.
| Mode | Destination | Payload |
|---|---|---|
| **Plain** | the tag's `writeTopic` | the value as UTF-8 text (`Raw`/`Scalar`) or a bare JSON literal (`Json`) |
| **Sparkplug B** | `spBv1.0/{group}/DCMD/{node}/{device}` for a device metric, `spBv1.0/{group}/NCMD/{node}` for an edge-node metric | a Sparkplug `Payload` carrying the metric **by name**, with an explicit `Datatype` and no `seq` |
Both publish with `retain: false`. A **Plain** write goes at **QoS 1** (STAB-26): the publish completes only
once the broker PUBACKs it, so `Good` means "the broker accepted it", and a broker refusal or a connection
that died under the send is `Bad`. **Sparkplug commands stay at QoS 0** — a redelivered NCMD/DCMD would be
re-applied. A Sparkplug metric the tag did not give a `dataType` is commanded with the type its live birth
certificate declared (STAB-25), the same authored-beats-birth precedence reads use — using the birth's **own**
Sparkplug datatype, not the lossy driver-type mapping, so Int8/UInt8/Text/Uuid/Bytes/File metrics are commanded
with their real type; a birth type the encoder cannot represent (any `*Array`, DataSet, Template, …) is refused
`BadNotSupported` rather than sent as another type. Retain is the one that
matters and is deliberately not configurable: a retained command would be replayed by the broker to the device on *every* future
subscribe — including the device's own reconnect — so one stale write would be re-applied forever.
**Plain QoS 1 is at-least-once.** Know the consequences before pointing a Plain `writeTopic` at a non-idempotent
device topic:
- A lost PUBACK can make a device subscribed at QoS ≥ 1 with a persistent session receive the command **twice**
(at QoS 0 delivery was downgraded and a duplicate could not happen).
- A lost PUBACK, or the `ConnectTimeoutSeconds` (15 s) deadline, reports `Bad` and the node is **reverted**, even
though the broker may already have delivered the write. `Good` is "the broker accepted it", `Bad` is "not
confirmed", never "not delivered".
- Each write holds `DriverInstanceActor`'s mailbox until the PUBACK arrives — up to 15 s per write, one after
another; comparable to a Modbus round trip.
- A broker advertising MQTT 5 Maximum QoS 0 will disconnect on every Plain write.
There is no per-tag QoS option; make the receiving topic idempotent (absolute setpoints, not increments).
### `writeTopic` is separate from `topic`, and its absence is meaningful
A tag commonly reads `…/state` and writes `…/set`, so the destination is its own authored field. A
Plain tag with **no** `writeTopic` is refused `BadNotWritable` — it is not published anywhere, and the
node is discovered `ViewOnly`.
> This is the whole point of the design. The one outcome worse than read-only nodes is a node that
> *looks* writable, accepts a write, returns `Good`, and publishes nothing. Publishing back onto the
> tag's own read topic would be worse still: it would echo straight back and read as a confirmed
> device value.
```jsonc
{
"topic": "factory/oven/temp/state",
"writeTopic": "factory/oven/temp/set", // omit for a read-only tag
"payloadFormat": "Scalar",
"dataType": "Float64"
}
```
Sparkplug needs no extra key — a command is addressed by the binding tuple the tag already carries.
### `Good` means published, not applied
MQTT has no application-level write acknowledgement, so a successful PUBLISH is the strongest truth
available at this seam — the same optimistic-Good posture Galaxy's fire-and-forget write takes. Device
truth arrives afterwards as the echoed value on the read topic and corrects the node through ordinary
ingest (write-outcome self-correction, [#5](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/5)).
**Inherited consequence:** this driver can never surface a failed *device* write, exactly as Galaxy
cannot. A publish that itself fails **is** reported `Bad` — the optimism covers what the device did
with the command, never whether it was sent.
### Status codes
| Situation | Status |
|---|---|
| Published | `Good` |
| Reference is not an authored tag | `BadNodeIdUnknown` |
| No write destination, or a data type with no command encoding | `BadNotWritable` |
| Value not representable in the tag's declared type | `BadTypeMismatch` |
| Publish failed, or no live broker connection | `BadCommunicationError` |
One status per request, in request order; one bad request never fails the rest of the batch.
### `WriteIdempotent` stays default-off
MQTT gives no write ack and a Sparkplug command is not necessarily idempotent (a pulse, a recipe step,
a counter increment), so the resilience pipeline must not replay one. Operators opt in per tag.
### Two things to know
- **`jsonPath` is not applied on write.** It is a read-side extraction from the *read* topic's
document; the write topic is a different topic whose payload shape nothing in the tag config
describes. A `Json` write emits a bare JSON literal. Reconstructing a wrapped document from an
extraction path would be guesswork.
- **NCMD is a shared channel.** The driver publishes its own rebirth requests
(`Node Control/Rebirth`) on `spBv1.0/{group}/NCMD/{node}` — the same topic an edge-node metric write
uses. Anything consuming that topic must match on the metric name. The live gate caught exactly this:
a rebirth NCMD arrived before the write command. DCMD has no such contention.
## Browse
`MqttDriverBrowser` opens a **read-only observation session**: it subscribes at QoS 0 to `#` (or
`{topicPrefix}/#`) under a `-browse-`-suffixed client id and builds a `/`-segment tree from whatever
publishes. **Nothing is ever published.** It is *inherently incomplete* — a topic that stays silent
during the window is invisible — so manual entry remains the escape hatch.
Bounds: 50 000 nodes (then `⚠ Observation truncated`), 1024-char topics / 256-char segments (then
`⚠ Some topics were too long`), 512-byte payload inspection. Sessions are reaped after 2 minutes idle.
**Sparkplug B browse serves a different tree from the same passive window**: observed NBIRTH/DBIRTH
certificates are decoded into `Group → EdgeNode → [Device] → Metric` (a birth is the only Sparkplug
message that names its metrics). Same node cap, same bounds, same "publishes nothing" guarantee.
The bespoke browser wins over the universal `DiscoveryDriverBrowser`, which would decline MQTT anyway
(`SupportsOnlineDiscovery` is `false`).
### Browse-commit writes the address the factory reads
A committed leaf's address is a **descriptor**, not a single reference string, so
`RawBrowseCommitMapper` reads it from the `AttributeInfo.AddressFields` the session states — Plain
emits `topic`; Sparkplug emits `groupId` / `edgeNodeId` / `deviceId?` / `metricName`, the names
`MqttTagConfigKeys` single-sources for both the mapper and `MqttTagDefinitionFactory`.
**It is never parsed out of the browse node id.** A Sparkplug node id is
`{group}/{node}[/{device}]::{metric}` and a metric name legitimately contains `/`
(`Node Control/Rebirth`), so splitting the id cannot recover the tuple. Which keys the session emits
is also how the mapper learns the shape — the driver *type* reaching it is just `Mqtt`, and the mode
lives on the driver config. A leaf whose attribute lookup returned nothing carries no address and is
refused **at commit, in words**, rather than committed as a tag that deploys clean and then reports
`BadNodeIdUnknown` forever.
### Request rebirth (Sparkplug only)
Births are never retained, so a healthy but quiet plant shows an **empty** picker tree until one
lands. The `Request rebirth` affordance in the browse modal is the way to fill it on demand: it
publishes a Sparkplug rebirth-request NCMD (`Node Control/Rebirth`) to the selected scope — the **one
and only** browse action that publishes anything.
- **Scope** is the last node clicked in the tree. A device or metric resolves **up to its owning edge
node** (Sparkplug has no device-scoped rebirth); a group fans out to every observed edge node
beneath it and is refused **whole** past 32, publishing nothing.
- **An empty tree can still arm a rebirth** ([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514),
fixed `7f53bd96`): when no scope is selected the panel offers a `group/edgeNode` input, so a plant
whose edge nodes all birthed before the window opened — the exact case the affordance exists for —
is reachable without a tree click. Deliberately an **edge node**, never a group: a typed group
broadcast is a self-inflicted broker storm on a large plant.
- **Two clicks, never one** — `Request rebirth…` arms a confirm panel naming the resolved scope and
its consequence; changing the selection disarms it.
- **Gated on `DriverOperator`**, enforced server-side in `BrowserSessionService.RequestRebirthAsync`
(fail-closed) before the session is touched; the UI gate is defence in depth. A refusal — policy,
over-wide group, unresolvable scope — is **shown**, not swallowed.
- 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.
## Test Connect
`MqttDriverProbe` performs **one CONNECT** (no SUBSCRIBE, no publish) under a `-probe-` client id and
disconnects. Success message: `"MQTT CONNECT OK"`. A rejected CONNACK is described by the same
`DescribeConnackRejection` the running driver uses, so the button and the driver report a rejection in
identical words. See [TestConnectProbes.md](TestConnectProbes.md).
## Testing
- **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` (**29 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.
<br/>The primary-host legs (`MqttPrimaryHostLiveTests`) include the one the ordinary lifecycle cannot
reach: `AbandonClaimForTests` drops the socket with **no** `DISCONNECT`, because a clean disconnect
makes the broker *discard* the Will by protocol rule — so a test driven through `StopAsync` would
observe the explicit stand-down `OFFLINE` and pass against an implementation that never armed a Will
at all.
- **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
- **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. 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
Every row is tracked. None is a silent failure — each either refuses in words or logs.
**Closed 2026-07-31:** write-through ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated), primary-host STATE publishing
([#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511), live-gated), structured
`Template`/`DataSet`/`PropertySet` metrics ([#515](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/515), live-gated), the slashed-metric browse-commit
([#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509)), the stale rebirth affordance ([#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510)), the `.Browser` layering exception
([#512](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/512)), the stray Sparkplug `payloadFormat` ([#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513)) and rebirth on a
not-yet-birthed plant ([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514)).
| Gap | Issue | Effect | Notes |
|---|:---:|---|---|
| **Rediscovery is advisory, not a rebuild** | [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) (closed, superseded) | A DBIRTH introducing a metric does not change the served OPC UA tree; an operator must re-browse the device and commit | **No longer "inert".** `DriverInstanceActor` now subscribes to `OnRediscoveryNeeded` and the raise rides the driver-health snapshot to the AdminUI `/hosts` page as a **re-browse chip**. It deliberately does not inject nodes: v3 authors raw tags through the `/raw` browse-commit flow, and grafting nodes at runtime would materialise ones nobody approved and no deployment artifact records. #507's injection path was deleted rather than revived |