# 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`, `IHistoryProvider`. 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 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. 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` 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. > **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 an `ApplyDelta` 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** 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 at **QoS 0, `retain: false`**. 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. ### `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 ``. `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 `