docs(drivers): Wave 0-2 tracking doc + Wave 1/2 implementation plans

Add a living progress tracker (docs/plans/2026-07-24-driver-expansion-tracking.md)
for the driver-expansion program's Waves 0-2, linked from the program design doc's
document map. One row per deliverable pointing at design + implementation plan + status.

Add executable implementation plans (plan .md + co-located .tasks.json) for the four
Wave 1/2 drivers, each authored by writing-plans methodology off its design doc and
mirroring the relevant existing driver:

- Modbus RTU        11 tasks  (extends Driver.Modbus; RTU-over-TCP, direct-serial descoped)
- SQL poll          22 tasks  (ISqlDialect seam, SQL Server only in v1; SQLite + central-SQL fixtures)
- MTConnect Agent   23 tasks  (browse free via Wave-0 seam; TrakHound-vs-hand-rolled = Task 0)
- MQTT/Sparkplug B  27 tasks  (P1 plain MQTT 0-14 then P2 Sparkplug 15-26; MQTTnet-5 pinning spike = Task 0)

Wave 0 (universal browser) remains merged (056887d6) with its live gate open (#468).
All four new plans are design-only -> plan-ready; nothing built yet.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 13:17:44 -04:00
parent 4550486144
commit 76e55fc112
10 changed files with 3379 additions and 1 deletions
@@ -0,0 +1,969 @@
# MQTT / Sparkplug B Driver Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Ship a standard Equipment-kind `Mqtt` driver that ingests plain MQTT (P1) then Sparkplug B (P2) into the OtOpcUa dual-namespace address space, with a bespoke observation browser, typed AdminUI editor, and TLS+auth-enforced fixtures.
**Architecture:** Three new `net10.0` projects mirror the OpcUaClient triad — `.Contracts` (transport-free options/DTOs/parser/enums), `.Driver` (subscribe-first `IDriver` holding one live MQTTnet-5 client for its whole lifetime, raising `OnDataChange` from the receive callback, with a hand-rolled reconnect loop since v5 dropped `ManagedMqttClient`), and `.Browser` (a passive `#`/birth observation-window `IBrowseSession`). Sparkplug B decodes vendored Eclipse Tahu protobuf via `Google.Protobuf`/`Grpc.Tools` over that same single client — no SparkplugNet.
**Tech Stack:** MQTTnet v5 (MIT, .NET Foundation, ships `net10.0`), vendored Eclipse Tahu `sparkplug_b.proto` + `Google.Protobuf` (already pinned 3.34.1) + `Grpc.Tools` (already pinned 2.76.0, build-time), bespoke browser.
**Source design:** docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md
**Phases:** P1 (plain MQTT) = Tasks 014 (a complete shippable milestone); P2 (Sparkplug B ingest) = Tasks 1526 (built on the P1 skeleton). Write-through (NCMD/DCMD/plain-publish) is deferred — see "Deferred / out of scope".
---
## Cross-cutting rules (apply to every task — program design §3.1)
- **Enum-serialization trap (systemic bug):** every JSON seam that (de)serializes `MqttDriverOptions` or a tag config — factory, probe, browser, **and** the AdminUI driver-config page + probe DTO — MUST share a `JsonSerializerOptions` carrying `new JsonStringEnumConverter()` + `PropertyNameCaseInsensitive=true` + `UnmappedMemberHandling=Skip`. `MqttMode`, `MqttPayloadFormat`, `protocolVersion` are enums; serialize them as **names**. Mirror `OpcUaClientDriverFactoryExtensions`.
- **Per-op deadline (R2-01 frozen-peer lesson):** every network call (connect / subscribe / probe / rebirth-publish) has a bounded linked-CTS deadline. No unbounded waits on a dead broker.
- **Ctor is connection-free:** `MqttDriver`/`MqttDriverBrowser` constructors touch no network; all connects happen in `InitializeAsync`/`OpenAsync` (the universal-browser `CanBrowse` throwaway-instance pattern depends on this).
- **Secrets from env:** broker `password` is blank in committed JSON, supplied via env (mirror `ServerHistorian__ApiKey`). Never commit or log creds.
- **Broker security — never ship anonymous/public-broker defaults:** default `useTls=true`, real auth. `allowUntrustedServerCertificate` + `caCertificatePath` mirror the ServerHistorian TLS knobs (dev/on-prem escape hatch, off by default). Fixtures run auth+TLS.
- **DriverType string is `"Mqtt"`** everywhere (driver page, probe, factory, editor map, validator, browser). One constant `DriverTypeNames.Mqtt`; grep to enforce (heed the `ModbusTcp`/`Modbus` mismatch lesson).
- **Live-verify discipline:** Razor binding + deploy-inertness bugs pass unit tests. Every picker/editor/deploy path gets a docker-dev `/run` live-verify (Tasks 14 + 26).
- **New-project csproj:** `net10.0`, `Nullable` + `ImplicitUsings` enabled, `TreatWarningsAsErrors=true` opted in per-csproj (not global).
---
## Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (this is the gate — the design's #1 dependency risk; nothing else starts until it is green)
**Files:**
- Modify: `Directory.Packages.props` (add `<PackageVersion Include="MQTTnet" Version="5.x" />`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (temporary spike form — references `MQTTnet` only to prove the graph restores; the transport ref is removed in Task 1, `.Contracts` is transport-free)
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the project)
**Why first:** The design (§2.1, §10) makes this the top dependency risk — the repo uses central package management with `CentralPackageTransitivePinningEnabled` **deliberately OFF** (Roslyn 5.0.0/4.12.0 split, `Directory.Packages.props:103` comment + "Transitive pinning breaks Roslyn build" memory). We must prove **MQTTnet v5 restores and builds clean on net10 in this exact pinning configuration** before writing any driver code. Fresh-restore red-vs-local-green (the NU1903 memory) also means we validate a clean restore, not just an incremental one.
### Steps
1. Confirm on NuGet the exact latest **MQTTnet v5** version carrying a `net10.0` TFM; pin that exact version in `Directory.Packages.props` (alphabetical position near `MessagePack`).
2. Scaffold the `.Contracts` csproj (net10.0, nullable, implicit usings, TWAE) with a single `<PackageReference Include="MQTTnet" />` (no `Version` — central management supplies it). Add to `slnx`.
3. Re-verify the two external-library record claims (design §2.1 checkbox): (a) MQTTnet v5 net10.0 TFM exists; (b) SparkplugNet still transitively pins MQTTnet 4.3.x with no net10.0 TFM. Record the confirmed versions in the tasks.json note. The hand-roll decision does not hinge on (b) — it is a confirm-the-record check.
4. Prove a **clean** restore + build:
```bash
dotnet restore src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: no NU1605/NU1608 version-conflict, no NU1903 audit error
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: Build succeeded, 0 warnings (TWAE on)
rm -rf ~/.nuget/packages/mqttnet && dotnet restore src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # fresh-restore proof
```
**Expected:** all three succeed with zero version-conflict / transitive-pin / audit errors. If restore fails on a 4/5 diamond or a missing net10 TFM, STOP — the hand-roll-Tahu decision (§2.1) is vindicated but the MQTTnet-5 pin itself is the blocker; resolve the exact version before proceeding.
5. Commit:
```bash
git commit -am "feat(mqtt): validate MQTTnet-5/net10 restore under central pinning (spike, P1 gate)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"
```
---
## Task 1 (P1): `.Contracts` — enums + `MqttDriverOptions` DTO
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 2 depends on this)
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (drop the temporary MQTTnet ref from Task 0 — `.Contracts` is transport-free; reference only `Core.Abstractions`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttMode.cs` (`Plain | SparkplugB`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttPayloadFormat.cs` (`Json | Raw | Scalar`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttProtocolVersion.cs` (`V311 | V500`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs` (broker conn + mode + `sparkplug`/`plain` sub-objects, §5.1)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj` (net10, xUnit + Shouldly)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
### Steps (TDD)
1. Write the failing test — options round-trip enum-as-name:
```csharp
public sealed class MqttDriverOptionsTests
{
private static readonly JsonSerializerOptions J = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
[Fact]
public void Deserialize_SparkplugConfig_ReadsModeAndSubObject()
{
const string json = """
{ "host":"10.100.0.35","port":8883,"useTls":true,"mode":"SparkplugB",
"sparkplug":{"groupId":"Plant1","hostId":"h1","requestRebirthOnGap":true} }
""";
var o = JsonSerializer.Deserialize<MqttDriverOptions>(json, J)!;
o.Mode.ShouldBe(MqttMode.SparkplugB);
o.UseTls.ShouldBeTrue();
o.Sparkplug!.GroupId.ShouldBe("Plant1");
o.Plain.ShouldBeNull();
}
[Fact]
public void Serialize_WritesEnumsAsNames_NotOrdinals()
{
var s = JsonSerializer.Serialize(new MqttDriverOptions { Mode = MqttMode.Plain }, J);
s.ShouldContain("\"Plain\"");
s.ShouldNotContain("\"mode\":0");
}
}
```
2. Run — expect FAIL (types don't exist):
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests # RED
```
3. Implement the enums + `MqttDriverOptions` (with nested `MqttSparkplugOptions` / `MqttPlainOptions`) matching §5.1 defaults (`useTls=true`, `connectTimeoutSeconds=15`, `reconnectMin/MaxBackoffSeconds=1/30`). Wire the test csproj into `slnx`.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): Contracts options DTO + enums (name-serialized)"` (+ session trailer).
---
## Task 2 (P1): `.Contracts` — `MqttTagDefinition` + `MqttEquipmentTagParser` (plain)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 6 depends on the resolver)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs` (parsed per-tag descriptor — carries plain OR sparkplug variant fields; plain fields populated in P1)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs` (`TryParse(reference, out def)` + `Inspect(reference)`, mirrors `ModbusEquipmentTagParser` / `ModbusTagDefinitionFactory`)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs`
### Steps (TDD)
1. Failing tests — parse a plain TagConfig blob, reject a typo'd enum (strict), and reject a wildcard topic:
```csharp
[Fact]
public void TryParse_PlainJsonBlob_PopulatesTopicAndPath()
{
const string r = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Double","qos":1}""";
MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue();
def!.Topic.ShouldBe("factory/oven/temp");
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
def.Name.ShouldBe(r); // the def Name == reference string (forward-router key)
}
[Fact]
public void TryParse_TypoedPayloadFormat_RejectsStrict()
=> MqttEquipmentTagParser.TryParse(
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}""", out _).ShouldBeFalse();
[Fact]
public void Inspect_WildcardTopic_ReturnsWarning()
=> MqttEquipmentTagParser.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}""").ShouldNotBeEmpty();
```
2. Run — expect FAIL.
3. Implement: a leading `{` marks a TagConfig blob; use `TagConfigJson.TryReadEnumStrict` for `payloadFormat`/`dataType` (typo → `false``BadNodeIdUnknown` upstream). `def.Name = reference`. `Inspect` returns a deploy-time warning list for present-but-invalid enums / unparseable blobs / wildcard tag-topics. Leave Sparkplug-descriptor parsing as a stub the P2 tasks fill (`groupId`/`edgeNodeId`/`deviceId`/`metricName`).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): plain tag parser + strict-enum descriptor"` (+ trailer).
---
## Task 3 (P1): `.Driver` project + `MqttConnection` connect/TLS/auth (bounded)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 4 extends `MqttConnection`)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj` (net10, refs `.Contracts` + `Core.Abstractions` + `Core` + `MQTTnet`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (MQTTnet-5 client wrapper: build options from `MqttDriverOptions` incl. TLS + CA-pin + credentials; `ConnectAsync(ct)` under `connectTimeoutSeconds` linked-CTS)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs`
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
### Steps (TDD)
1. Failing test — bounded connect against a dead endpoint fails fast (no hang), and TLS-option assembly honours the knobs. Use a closed loopback port for the deadline test:
```csharp
[Fact]
public async Task ConnectAsync_DeadBroker_FailsWithinDeadline_NoHang()
{
var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 2 };
var conn = new MqttConnection(opts, driverId: "t", logger: null);
var sw = Stopwatch.StartNew();
await Should.ThrowAsync<Exception>(() => conn.ConnectAsync(CancellationToken.None));
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5)); // deadline honoured, not wedged
}
[Fact]
public void BuildClientOptions_UseTlsWithCaPin_SetsTlsAndValidatesChain()
{
var opts = new MqttDriverOptions { Host="h", Port=8883, UseTls=true,
AllowUntrustedServerCertificate=false, CaCertificatePath="/tmp/ca.pem" };
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions.UseTls.ShouldBeTrue();
}
```
2. Run — expect FAIL.
3. Implement `MqttConnection`: static `BuildClientOptions(opts, clientIdSuffix)` (host/port, protocol version, clientId + optional suffix, keep-alive, clean-session, credentials, TLS with `AllowUntrustedServerCertificate` → custom cert validator, `CaCertificatePath` → chain pin). `ConnectAsync(ct)` links `ct` with a `connectTimeoutSeconds` CTS. Ctor stores options only — connection-free.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): MqttConnection connect/TLS/auth under bounded deadline"` (+ trailer).
---
## Task 4 (P1): `MqttConnection` hand-rolled reconnect loop (backoff + resubscribe)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (reconnect loop: exponential backoff `min→max`; on `DisconnectedAsync` schedule reconnect; on reconnect fire a `Reconnected` callback so the driver re-subscribes; expose `State` = Connected/Reconnecting/Faulted + `LastMessageAgeUtc`)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs`
**Why high-risk:** MQTTnet v5 dropped v4's `ManagedMqttClient` (§8) — there is no library-managed reconnect. Subscriptions do not survive a clean session; a reconnect that forgets to re-subscribe silently goes dark. Backoff that ignores its cap can hot-loop a dead broker.
### Steps (TDD)
1. Failing tests — backoff schedule is bounded + monotone-to-cap, and a `Reconnected` event drives a re-subscribe callback. Test the backoff calculator as a pure function (no live broker):
```csharp
[Theory]
[InlineData(0, 1)] [InlineData(1, 2)] [InlineData(2, 4)] [InlineData(10, 30)] // caps at max=30
public void NextBackoff_IsExponentialClampedToMax(int attempt, int expectedSeconds)
=> MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30)
.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
[Fact]
public async Task OnReconnect_InvokesResubscribeCallback()
{
var conn = new MqttConnection(new MqttDriverOptions(), "t", null);
var fired = 0; conn.Reconnected += () => { fired++; return Task.CompletedTask; };
await conn.RaiseReconnectedForTest(); // test seam
fired.ShouldBe(1);
}
```
2. Run — expect FAIL.
3. Implement: `NextBackoff(attempt, min, max)` pure; a reconnect worker that loops on disconnect with backoff, honours `cleanSession`/session-expiry, re-subscribes via the `Reconnected` callback (idempotent insurance even for persistent sessions), and (Sparkplug, P2) re-requests rebirth. `State` transitions Connected↔Reconnecting; Faulted only on unrecoverable config. Never block the MQTTnet dispatcher thread.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): hand-rolled reconnect loop with bounded backoff + resubscribe"` (+ trailer).
---
## Task 5 (P1): `LastValueCache` + `IReadable`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 6
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs` (`FullReference → DataValueSnapshot`, thread-safe)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/LastValueCacheTests.cs`
### Steps (TDD)
1. Failing test — unseen reference returns a per-ref `GoodNoData`/uncertain snapshot (never throws the batch); a seeded ref returns last value:
```csharp
[Fact]
public void Read_UnseenRef_ReturnsPerRefNoData_DoesNotThrow()
{
var c = new LastValueCache();
var snap = c.Read("factory/oven/temp#$.value");
snap.StatusCode.ShouldBe(StatusCodes.GoodNoData); // or Uncertain — per IReadable contract, per-ref status
}
[Fact]
public void Update_ThenRead_ReturnsLastValue()
{
var c = new LastValueCache();
c.Update("k", DataValueSnapshot.Good(42.0, DateTime.UtcNow));
c.Read("k").Value.ShouldBe(42.0);
}
```
2. Run — expect FAIL.
3. Implement `LastValueCache` (concurrent dict); `MqttDriver.ReadAsync` (Task 7) returns `references.Select(cache.Read)` — batch never throws, per-ref `StatusCode` carries "not yet observed".
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): last-value cache backing IReadable (per-ref no-data)"` (+ trailer).
---
## Task 6 (P1): `ISubscribable` — plain topic subscribe → `OnDataChange` + retained seed
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs` (registers `FullReference → MqttTagDefinition` via the shared `EquipmentTagRefResolver<MqttTagDefinition>`; dedupes/coalesces topics; on message: match topic → authored tag(s), extract value at `jsonPath`/raw/scalar, raise `OnDataChange`; seed from retained message on subscribe)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (expose `SubscribeAsync(filters, ct)` under a bounded deadline + a message-received event)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs`
### Steps (TDD)
1. Failing test — feed a synthetic received message through the manager (no live broker); assert routing + JSONPath extraction fire `OnDataChange` with the right `FullReference`:
```csharp
[Fact]
public void OnMessage_MatchingTopic_RaisesDataChangeAtJsonPath()
{
var mgr = new MqttSubscriptionManager();
var handle = mgr.Register(new[] { """{"topic":"f/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Double"}""" });
string? gotRef = null; object? gotVal = null;
mgr.OnDataChange += (_, e) => { gotRef = e.FullReference; gotVal = e.Snapshot.Value; };
mgr.HandleMessage("f/oven/temp", """{"value":21.5}"""u8.ToArray(), retained: false);
gotVal.ShouldBe(21.5);
gotRef.ShouldContain("f/oven/temp");
}
[Fact]
public void OnMessage_UnauthoredTopic_RaisesNothing() // chatty broker must not auto-provision
{
var mgr = new MqttSubscriptionManager();
mgr.Register(Array.Empty<string>());
var fired = false; mgr.OnDataChange += (_, _) => fired = true;
mgr.HandleMessage("random/topic", "1"u8.ToArray(), retained: false);
fired.ShouldBeFalse();
}
```
2. Run — expect FAIL.
3. Implement using `EquipmentTagRefResolver<MqttTagDefinition>` (parse-once cache, as Modbus does). `HandleMessage` matches topic → tag(s), extracts (Json→JSONPath token→typed value; Raw→bytes-as-string; Scalar→parse), updates `LastValueCache`, raises `OnDataChange`. Retained-flagged messages seed initial value. `SubscribeAsync` returns an `ISubscriptionHandle` whose `DiagnosticId` names mode+filter; completes under a bounded SUBACK deadline (SUBACK failure → per-ref Bad, not hang).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): plain subscribe→OnDataChange with retained seed + ref resolver"` (+ trailer).
---
## Task 7 (P1): `MqttDriver` shell — `IDriver` + authored-only `ITagDiscovery` + `IHostConnectivityProbe`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 8/9 wire it)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (`IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable`; composes `MqttConnection` + `MqttSubscriptionManager` + `LastValueCache`)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs`
### Steps (TDD)
1. Failing test — `DiscoverAsync` streams **only authored tags** into a capturing `IAddressSpaceBuilder`; plain-mode `RediscoverPolicy == Once`; `SupportsOnlineDiscovery == false`:
```csharp
[Fact]
public async Task DiscoverAsync_Plain_StreamsAuthoredTagsOnly_PolicyOnce()
{
var driver = new MqttDriver(new MqttDriverOptions { Mode = MqttMode.Plain }, "d", null);
driver.SetAuthoredTagsForTest(new[] { """{"topic":"f/t","payloadFormat":"Raw","dataType":"String"}""" });
var b = new CapturingAddressSpaceBuilder();
await driver.DiscoverAsync(b, CancellationToken.None);
b.Variables.Count.ShouldBe(1);
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
}
```
2. Run — expect FAIL.
3. Implement: `InitializeAsync` deserializes options (shared `JsonSerializerOptions`), builds + connects `MqttConnection`, subscribes the mode-appropriate filter. `ReinitializeAsync` applies deltas in place (never crash → Faulted). `ShutdownAsync` disconnects/disposes. `GetHealth` = Connected/Reconnecting/Faulted + last-message-age. `GetMemoryFootprint` = caches; `FlushOptionalCachesAsync` = no-op (birth/alias are correctness state — forbidden to flush; last-value backs `IReadable`). `DiscoverAsync` replays authored tags only; `RediscoverPolicy => Once` (plain). `IRediscoverable.OnRediscoveryNeeded` never fires in plain mode.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): MqttDriver shell — lifecycle + authored-only discovery (Once)"` (+ trailer).
---
## Task 8 (P1): `MqttDriverProbe` — CONNECT handshake
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs` (`IDriverProbe`, `DriverType => "Mqtt"`; parse config with the shared options, open a CONNECT under the timeout, green + latency on CONNACK-accepted, targeted error on refused/TLS/auth/timeout — mirrors `ModbusDriverProbe`)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs`
### Steps (TDD)
1. Failing test — `DriverType` is `"Mqtt"`; a dead endpoint yields a failed (not thrown) probe result within the deadline:
```csharp
[Fact]
public void DriverType_IsCanonicalMqtt() => new MqttDriverProbe().DriverType.ShouldBe("Mqtt");
[Fact]
public async Task ProbeAsync_DeadBroker_ReturnsFailedResult_WithinDeadline()
{
var r = await new MqttDriverProbe().ProbeAsync(
"""{"host":"127.0.0.1","port":1,"useTls":false,"connectTimeoutSeconds":2}""", CancellationToken.None);
r.Success.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
}
```
2. Run — expect FAIL.
3. Implement; CONNACK-accepted is the MQTT "device is answering" proof. Use the **shared** `JsonSerializerOptions` (enum-as-name).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): MqttDriverProbe CONNECT handshake"` (+ trailer).
---
## Task 9 (P1): Factory + `DriverTypeNames.Mqtt` + Host registration
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs` (`DriverTypeName = "Mqtt"`, shared `JsonSerializerOptions`, `Register(registry, loggerFactory)` — mirror `OpcUaClientDriverFactoryExtensions`)
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs` (add `public const string Mqtt = "Mqtt";` + append to the `All` list)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs` (add `Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory);` in `Register(...)` near line 146; add `using MqttProbe = Driver.Mqtt.MqttDriverProbe;` + `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());` in `AddOtOpcUaDriverProbes` near line 124)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` (ProjectReference the `.Driver` assembly)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs`
### Steps (TDD)
1. Failing test — `Register` binds the `"Mqtt"` type, and the factory builds an `MqttDriver` from JSON with string enums:
```csharp
[Fact]
public void Register_ThenCreate_BuildsMqttDriver()
{
var registry = new DriverFactoryRegistry();
MqttDriverFactoryExtensions.Register(registry);
var d = registry.Create("Mqtt", "d1", """{"host":"h","port":1883,"mode":"Plain"}""");
d.ShouldBeOfType<MqttDriver>();
}
[Fact]
public void DriverTypeName_MatchesConstant()
=> MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt);
```
2. Run — expect FAIL.
3. Implement + wire both host sites. Build the whole solution to confirm registration compiles:
```bash
dotnet build ZB.MOM.WW.OtOpcUa.slnx # expect: Build succeeded
```
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): factory + DriverTypeNames.Mqtt + host factory/probe registration"` (+ trailer).
---
## Task 10 (P1): `.Browser` — bespoke `#`-observation browser (passive)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 8
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj` (net10, refs `.Contracts` + `Commons(.Browsing)` + `MQTTnet`)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs` (`IDriverBrowser`, `DriverType => "Mqtt"`; `OpenAsync` connects with a `{clientId}-browse-{guid8}` suffix under a clamped 530 s budget; subscribes `#`/`{topicPrefix}#`; returns `MqttBrowseSession`; **publishes nothing**)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs` (`IBrowseSession` over a thread-safe accumulating observed tree; `RootAsync`/`ExpandAsync` split topic segments on `/`, leaf = `Kind=Leaf`; `AttributesAsync` = synthetic attribute w/ inferred type + last payload snippet; `DisposeAsync` disconnects best-effort)
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs`
### Steps (TDD)
1. Failing test — feed observed topics into the session's accumulating tree (test seam, no live broker); assert `RootAsync`/`ExpandAsync` build the segment tree and **no publish** occurs on any browse call:
```csharp
[Fact]
public async Task ExpandAsync_BuildsTopicSegmentTree_FromObservedTopics()
{
var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/line3/oven/temp");
var root = await s.RootAsync(CancellationToken.None);
root.ShouldContain(n => n.BrowseName == "factory");
var lvl2 = await s.ExpandAsync("factory", CancellationToken.None);
lvl2.ShouldContain(n => n.BrowseName == "line3");
}
[Fact]
public async Task BrowseCalls_PublishNothing() // browse is read-only
{
var s = new MqttBrowseSession(MqttMode.Plain);
await s.RootAsync(CancellationToken.None);
s.PublishCountForTest.ShouldBe(0);
}
```
2. Run — expect FAIL.
3. Implement the accumulating tree + passive `OpenAsync`. In P1 only the plain `#` path is live; leave a Sparkplug hook the P2 tasks fill (`Group→EdgeNode→Device→Metric` + `RequestRebirthAsync`).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): bespoke passive #-observation browser (plain)"` (+ trailer).
---
## Task 11 (P1): Register the bespoke browser (overrides universal fallback)
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 12
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (add `services.AddSingleton<IDriverBrowser, MqttDriverBrowser>();` alongside the existing two near line 7576)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (ProjectReference `.Browser`)
### Steps
1. Add the registration + project reference. Registering for `DriverType="Mqtt"` overrides the universal fallback (`BrowserSessionService` resolves bespoke-first).
2. Build: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` — expect success.
3. Commit: `git commit -am "feat(mqtt): register MqttDriverBrowser (bespoke-first for Mqtt)"` (+ trailer).
---
## Task 12 (P1): Typed AdminUI editor + validator (plain shape)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 11
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs` (thin typed model over a preserved `JsonObject` key bag; `FromJson`/`ToJson`/`Validate`, preserves unknown keys incl. history keys; carries a `Mode` field selecting sub-shape — P1 implements Plain `Validate`, Sparkplug stub filled in Task 24)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (`[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor)`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs` (`[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate()`)
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor` (copy the Modbus editor template — mode-switch at top, per-mode field group, client-side `Validate()`)
- Create: `tests/Server/.../MqttTagConfigModelTests.cs` (co-locate with the existing AdminUI test project — verify path with `find tests/Server -name "*TagConfigModel*Tests.cs"`)
### Steps (TDD)
1. Failing test — round-trip preserves unknown/history keys; plain `Validate` requires concrete topic + jsonPath-when-Json; re-derives `FullName`:
```csharp
[Fact]
public void FromJson_ToJson_PreservesUnknownKeys()
{
var m = MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","isHistorized":true}""");
m.ToJson().ShouldContain("isHistorized");
}
[Fact]
public void Validate_PlainWildcardTopic_Fails()
=> MqttTagConfigModel.FromJson("""{"topic":"a/+/c","payloadFormat":"Raw","dataType":"String"}""")
.Validate().ShouldNotBeEmpty();
[Fact]
public void Validate_JsonWithoutPath_Fails()
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""")
.Validate().ShouldNotBeEmpty();
```
2. Run — expect FAIL.
3. Implement the model (mirror `OpcUaClientTagConfigModel`); `ToJson` writes PascalCase `FullName` re-derived from descriptor (`{topic}#{jsonPath}` plain). Build the `.razor`. Register both map entries.
4. Run — expect PASS + `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI`.
5. Commit: `git commit -am "feat(mqtt): typed tag editor + validator (plain mode)"` (+ trailer).
---
## Task 13 (P1): Mosquitto + JSON-publisher fixture (TLS+auth) + env-gated live suite
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests.csproj`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` (`eclipse-mosquitto` with **auth + TLS** on `:8883`, plain `:1883` for smoke only; a JSON-publisher container emitting `retain=true` JSON on a few topics — `mosquitto_pub` loop or `paho-mqtt` script; `project=lmxopcua` label applied host-side)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/mosquitto.conf` + password/cert material generator script (creds via env, never real secrets committed)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/PlainMqttLiveTests.cs` (category `LiveIntegration`, gated on `MQTT_FIXTURE_ENDPOINT` — skips clean when unset → macOS-offline-safe)
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
### Steps
1. Write the compose with **auth + TLS** (never anonymous). Add the JSON publisher with `retain=true`.
2. Write env-gated tests: connect/TLS/auth; plain subscribe + retained-read seed; unauthored-topic silence. `[SkippableFact]` reading `MQTT_FIXTURE_ENDPOINT`.
3. Confirm offline skip:
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests # expect: all Skipped (no env var)
```
4. Deploy the fixture to the docker host and run live (design §9):
```bash
lmxopcua-fix sync mqtt && lmxopcua-fix up mqtt
export MQTT_FIXTURE_ENDPOINT=10.100.0.35:8883
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests --filter "Category=LiveIntegration" # expect: PASS
```
5. Commit: `git commit -am "test(mqtt): Mosquitto TLS+auth fixture + env-gated plain live suite"` (+ trailer).
---
## Task 14 (P1): Live `/run` verify on docker-dev — **P1 MILESTONE COMPLETE**
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `CLAUDE.md` (add the MQTT fixture endpoint to the Docker Workflow endpoint list: `10.100.0.35:1883/8883`, `MQTT_FIXTURE_ENDPOINT`)
- Modify: `docs/plans/2026-07-15-driver-expansion-tracking.md` (mark MQTT P1 code-complete)
### Steps
1. On docker-dev (`:9200`, login disabled — run it yourself, do not defer): author an `Mqtt` driver (Plain mode) + a tag via the `/uns` picker (bespoke `#`-observation browser) or the typed editor; deploy.
2. Confirm via Client.CLI the node carries live broker values:
```bash
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>"
```
3. Confirm the typed editor renders (Razor binding bugs pass unit tests — live-verify is mandatory). Update CLAUDE.md + tracking doc.
4. Commit: `git commit -am "docs(mqtt): P1 plain-MQTT milestone live-verified; endpoint recorded"` (+ trailer).
---
# ── P2: Sparkplug B ingest (builds on the P1 skeleton) ──
## Task 15 (P2): Vendor Tahu proto + `Grpc.Tools` codegen
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (P2 root)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Protos/sparkplug_b.proto` (vendored Eclipse Tahu `sparkplug_b.proto`, pinned to a known Tahu commit with a provenance comment header)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (add `Google.Protobuf` + build-time `Grpc.Tools` (`PrivateAssets=all`); `<Protobuf Include="Protos/sparkplug_b.proto" GrpcServices="None" />` — message-only codegen, this repo's first in-repo protoc step)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugProtoCodegenTests.cs`
### Steps (TDD)
1. Failing test — the generated `Payload`/`Metric` types exist and round-trip a hand-built payload:
```csharp
[Fact]
public void GeneratedPayload_RoundTrips()
{
var p = new Org.Eclipse.Tahu.Protobuf.Payload { Seq = 3 };
p.Metrics.Add(new Org.Eclipse.Tahu.Protobuf.Payload.Types.Metric { Name = "Temperature", Alias = 5 });
var back = Org.Eclipse.Tahu.Protobuf.Payload.Parser.ParseFrom(p.ToByteArray());
back.Seq.ShouldBe(3ul);
back.Metrics[0].Alias.ShouldBe(5ul);
}
```
2. Run — expect FAIL (no generated types).
3. Vendor the proto (provenance comment: Tahu commit hash + URL); wire `Grpc.Tools`. If in-repo protoc proves objectionable, fall back to checking in the generated C# with the same provenance comment (design §2.1). Build:
```bash
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: proto compiles, 0 warnings
```
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): vendor Tahu sparkplug_b.proto + Grpc.Tools codegen"` (+ trailer).
---
## Task 16 (P2): `SparkplugCodec` decode + golden payloads
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 17
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugCodec.cs` (decode `Payload`/`Metric` from wire bytes → a driver-side struct; encode NCMD deferred to `RebirthRequester` Task 20 / write-through P3)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/nbirth.bin` + `ndata.bin` (golden byte vectors, generated once from a hand-built payload and committed)
### Steps (TDD)
1. Failing test — decode a golden NBIRTH: seq + metric name/alias/datatype/value survive:
```csharp
[Fact]
public void Decode_GoldenNbirth_ExtractsMetrics()
{
var payload = SparkplugCodec.Decode(File.ReadAllBytes("Golden/nbirth.bin"));
payload.Seq.ShouldBe((byte)0);
payload.Metrics.ShouldContain(m => m.Name == "Temperature" && m.Alias == 5 && m.DataType == SparkplugDataType.Float);
}
```
2. Run — expect FAIL.
3. Implement `SparkplugCodec.Decode`; generate the golden vectors in a one-off `[Fact(Skip="generator")]` or a small helper, commit the `.bin` files.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): SparkplugCodec decode + golden payload vectors"` (+ trailer).
---
## Task 17 (P2): `SparkplugTopic` + `SparkplugDataType.ToDriverDataType`
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 16
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugTopic.cs` (parse/format `spBv1.0/{group}/{type}/{node}[/{device}]`; `type` ∈ NBIRTH/DBIRTH/NDATA/DDATA/NDEATH/DDEATH/NCMD/DCMD/STATE)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/SparkplugDataType.cs` (enum + `ToDriverDataType()` per the §3.5 map: Int8→Int16, UInt8→UInt16, DataSet/Template unsupported, `*Array`→element+IsArray)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugTopicTests.cs`
### Steps (TDD)
1. Failing tests — topic parse extracts group/type/node/device; datatype map widens Int8→Int16 and marks DataSet unsupported:
```csharp
[Fact]
public void Parse_DeviceData_ExtractsAllSegments()
{
var t = SparkplugTopic.Parse("spBv1.0/Plant1/DDATA/EdgeA/Filler1");
t.GroupId.ShouldBe("Plant1"); t.Type.ShouldBe(SparkplugMessageType.DDATA);
t.EdgeNodeId.ShouldBe("EdgeA"); t.DeviceId.ShouldBe("Filler1");
}
[Theory]
[InlineData(SparkplugDataType.Int8, DriverDataType.Int16)]
[InlineData(SparkplugDataType.UInt8, DriverDataType.UInt16)]
[InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
public void ToDriverDataType_MapsAndWidens(SparkplugDataType s, DriverDataType d)
=> s.ToDriverDataType().ShouldBe(d);
```
2. Run — expect FAIL.
3. Implement.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): Sparkplug topic parse + datatype map"` (+ trailer).
---
## Task 18 (P2): `BirthCache` + `AliasTable` — bind-by-name, rebuild-per-birth
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 19
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs` (per `(edgeNode,device)` alias→(name,datatype); **rebuilt wholesale each birth, never merged**)
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/BirthCache.cs` (NBIRTH/DBIRTH metric catalog: name/alias/datatype/last value)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/AliasBindingTests.cs`
**Why high-risk:** §3.6 invariants #1#2 — binding data by alias silently mis-routes when an alias is reused for a different metric across a rebirth. Bind by **stable metric NAME**; the alias is a per-birth cache only.
### Steps (TDD)
1. Failing tests — the two load-bearing invariants:
```csharp
[Fact]
public void Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias()
{
var t = new AliasTable();
t.RebuildFromBirth(new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float) });
t.Resolve(alias: 5).Name.ShouldBe("Temperature");
// rebirth: alias 5 now means a DIFFERENT metric
t.RebuildFromBirth(new[] { (name:"Pressure", alias:5ul, dt:SparkplugDataType.Float) });
t.Resolve(alias: 5).Name.ShouldBe("Pressure"); // wholesale replace, not merge
}
[Fact]
public void RebuildFromBirth_DoesNotMergeStaleAliases()
{
var t = new AliasTable();
t.RebuildFromBirth(new[] { (name:"A", alias:1ul, dt:SparkplugDataType.Int32) });
t.RebuildFromBirth(new[] { (name:"B", alias:2ul, dt:SparkplugDataType.Int32) });
t.TryResolve(alias: 1, out _).ShouldBeFalse(); // alias 1 gone after rebirth
}
```
2. Run — expect FAIL.
3. Implement — `RebuildFromBirth` replaces the whole map. `BirthCache` keeps the metric catalog + last value keyed by name.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): AliasTable/BirthCache — bind-by-name, rebuild-per-birth"` (+ trailer).
---
## Task 19 (P2): `SequenceTracker` — seq-gap + bdSeq death-tie
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 18
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs` (per edge-node `seq` 0255 wrap gap detection; `bdSeq` ties NDEATH↔NBIRTH)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SequenceTrackerTests.cs`
**Why high-risk:** §3.6 invariant #3 — a wrong wrap boundary (255→0 is NOT a gap) either misses real gaps (stale data) or false-positives every wrap (rebirth-storm).
### Steps (TDD)
1. Failing tests — contiguous ok, wrap ok, gap detected:
```csharp
[Fact]
public void Sequence_WrapAt255IsContiguous_NotAGap()
{
var s = new SequenceTracker();
s.Accept(254).ShouldBeTrue(); s.Accept(255).ShouldBeTrue(); s.Accept(0).ShouldBeTrue(); // wrap
}
[Fact]
public void Sequence_SkippedValue_IsGap()
{
var s = new SequenceTracker();
s.Accept(10).ShouldBeTrue();
s.Accept(12).ShouldBeFalse(); // gap → caller requests rebirth
}
```
2. Run — expect FAIL.
3. Implement (next-expected = `(last+1) & 0xFF`); `bdSeq` compare helper.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): SequenceTracker seq-gap + bdSeq death-tie"` (+ trailer).
---
## Task 20 (P2): `RebirthRequester` — NCMD encode
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/RebirthRequester.cs` (encode an NCMD writing `Node Control/Rebirth = true` to `spBv1.0/{group}/NCMD/{node}`; publish under a bounded deadline)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/RebirthRequesterTests.cs`
### Steps (TDD)
1. Failing test — encoded NCMD carries the `Node Control/Rebirth`=true metric and targets the right topic:
```csharp
[Fact]
public void BuildRebirthNcmd_EncodesControlMetric_AndTopic()
{
var (topic, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
topic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
var p = SparkplugCodec.Decode(bytes);
p.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true));
}
```
2. Run — expect FAIL.
3. Implement (encode path via the generated proto).
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): RebirthRequester NCMD encode"` (+ trailer).
---
## Task 21 (P2): Sparkplug ingest state machine — the §3.6 matrix
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (integrates Tasks 1620 into the driver)
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs` (routes decoded messages: N/DBIRTH → rebuild `AliasTable` + `BirthCache`; N/DDATA → resolve alias→name → map to authored `FullReference` **by (group,node,device,metricName)**`OnDataChange`; N/DDEATH → emit STALE/Bad snapshots; seq-gap / unknown-alias / data-before-birth → `RebirthRequester` gated on `requestRebirthOnGap`; STATE/primary-host handling; late-join rebirth on connect)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (Sparkplug-mode subscribe `spBv1.0/{group}/#` (+ STATE) → `SparkplugIngestor`; reconnect → re-subscribe + request rebirth)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorTests.cs`
**Why high-risk:** this is the §3.6 correctness core — the #1 risk in the design. Test the full matrix: rebirth, missed-birth, seq-gap, alias-reuse-across-rebirth, death→stale→rebirth.
### Steps (TDD)
1. Failing tests — the §3.6 matrix, driven through the ingestor with synthetic decoded messages (no live broker):
```csharp
[Fact]
public void Birth_Then_Data_ResolvesByAlias_RaisesOnDataChangeByName()
{
var ing = new SparkplugIngestor(...); // authored tag: Plant1/EdgeA/Filler1:Temperature
string? firedRef = null; ing.OnDataChange += (_, e) => firedRef = e.FullReference;
ing.HandleDbirth("Plant1","EdgeA","Filler1", new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float, val:(object)0f) });
ing.HandleDdata("Plant1","EdgeA","Filler1", seq:1, new[] { (alias:5ul, val:(object)21.5f) });
firedRef.ShouldContain("Filler1:Temperature");
}
[Fact]
public void DataBeforeBirth_RequestsRebirth()
{
var ncmds = new List<string>();
var ing = new SparkplugIngestor(..., publishNcmd: (t,_) => ncmds.Add(t));
ing.HandleDdata("Plant1","EdgeA","Filler1", seq:0, new[] { (alias:9ul, val:(object)1f) });
ncmds.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
}
[Fact]
public void Ddeath_EmitsStaleForDeviceMetrics_NextBirthRestoresGood()
{
var ing = new SparkplugIngestor(...);
var quals = new List<StatusCode>(); ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
ing.HandleDbirth("Plant1","EdgeA","Filler1", new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float, val:(object)0f) });
ing.HandleDdeath("Plant1","EdgeA","Filler1");
quals.ShouldContain(q => StatusCode.IsBad(q)); // STALE/Bad on death
}
```
2. Run — expect FAIL.
3. Implement the ingestor holding the §3.6 invariants. Route via `EquipmentTagRefResolver` keyed by `(group,node,device,metricName)`.
4. Run — expect PASS (all matrix cases).
5. Commit: `git commit -am "feat(mqtt): Sparkplug ingest state machine (birth/alias/seq/rebirth/death→stale)"` (+ trailer).
---
## Task 22 (P2): `ITagDiscovery` `UntilStable` + `IRediscoverable`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (`RediscoverPolicy => UntilStable` in Sparkplug mode — authored tags' datatypes fill in as births arrive; `DiscoverAsync` re-streams authored tags with resolved datatypes from `BirthCache`; fire `OnRediscoveryNeeded` on a **new DBIRTH** or a rebirth with a changed metric set, `ScopeHint` = edge-node/device folder path)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs`
### Steps (TDD)
1. Failing tests — Sparkplug policy is `UntilStable`; a new DBIRTH fires `OnRediscoveryNeeded`; a tag authored before its birth picks up the birth datatype:
```csharp
[Fact]
public void SparkplugMode_RediscoverPolicy_IsUntilStable()
=> new MqttDriver(new MqttDriverOptions { Mode = MqttMode.SparkplugB }, "d", null)
.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable);
[Fact]
public async Task NewDbirth_FiresOnRediscoveryNeeded()
{
var d = new MqttDriver(new MqttDriverOptions { Mode = MqttMode.SparkplugB }, "d", null);
var fired = false; ((IRediscoverable)d).OnRediscoveryNeeded += (_, _) => fired = true;
d.SimulateNewDbirthForTest("Plant1","EdgeA","Filler2");
fired.ShouldBeTrue();
}
```
2. Run — expect FAIL.
3. Implement.
4. Run — expect PASS.
5. Commit: `git commit -am "feat(mqtt): Sparkplug UntilStable discovery + rediscover-on-DBIRTH"` (+ trailer).
---
## Task 23 (P2): Sparkplug browser tree + `AttributesAsync` + `RequestRebirthAsync`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 24
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs` (Sparkplug tree Group→EdgeNode→Device→Metric from observed births; `AttributesAsync` = self-describing metric `AttributeInfo{Name, DriverDataType from birth, IsArray, SecurityClass}`; `RequestRebirthAsync(scope)` — the **only** publishing session member, scoped to selected group/edge-node, via the `RebirthRequester` path)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs` (Sparkplug discovery filter `spBv1.0/{groupId}/#`; `OpenAsync` still publishes nothing)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs` (dispatch the MQTT-specific `RequestRebirthAsync` for MQTT sessions, gated by the same `DriverOperator` policy that gates the picker; Info-log the target scope)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs`
### Steps (TDD)
1. Failing tests — birth-driven tree fills; `OpenAsync`/`RootAsync`/`ExpandAsync` publish **nothing**; `RequestRebirthAsync` publishes exactly one scoped NCMD:
```csharp
[Fact]
public async Task SparkplugTree_FillsFromObservedBirths_BrowseNeverPublishes()
{
var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1","EdgeA","Filler1","Temperature", SparkplugDataType.Float);
var groups = await s.RootAsync(default);
groups.ShouldContain(n => n.BrowseName == "Plant1");
s.PublishCountForTest.ShouldBe(0); // passive
}
[Fact]
public async Task RequestRebirthAsync_ScopedToNode_PublishesOneNcmd()
{
var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1","EdgeA","Filler1","T", SparkplugDataType.Float);
await s.RequestRebirthAsync(scope: "Plant1/EdgeA");
s.PublishCountForTest.ShouldBe(1);
}
```
2. Run — expect FAIL.
3. Implement. `RequestRebirthAsync` is never fired by `OpenAsync`/`RootAsync`/`ExpandAsync` — only on explicit operator click.
4. Run — expect PASS + `dotnet build` the AdminUI.
5. Commit: `git commit -am "feat(mqtt): Sparkplug birth-driven browser tree + scoped Request-rebirth action"` (+ trailer).
---
## Task 24 (P2): AdminUI editor Sparkplug mode shape + validator
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 23
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs` (Sparkplug `Validate`: `groupId`/`edgeNodeId`/`metricName` required, `deviceId` optional, `dataType` a known Sparkplug type; `ToJson` re-derives `FullName` = `{group}/{node}[/{device}]:{metric}`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor` (Sparkplug field group under the mode switch)
- Modify: `tests/Server/.../MqttTagConfigModelTests.cs`
### Steps (TDD)
1. Failing tests — Sparkplug validation + FullName derivation:
```csharp
[Fact]
public void Validate_SparkplugMissingMetricName_Fails()
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA"}""").Validate().ShouldNotBeEmpty();
[Fact]
public void ToJson_Sparkplug_DerivesFullName()
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float"}""")
.ToJson().ShouldContain("Plant1/EdgeA/Filler1:Temperature");
```
2. Run — expect FAIL.
3. Implement.
4. Run — expect PASS + AdminUI build.
5. Commit: `git commit -am "feat(mqtt): typed tag editor Sparkplug mode + validation"` (+ trailer).
---
## Task 25 (P2): C# edge-node simulator fixture + §3.6 live matrix
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/` (a project-owned C# edge-node simulator using the **same** MQTTnet-5 + Tahu-proto path the driver uses — publishes NBIRTH/DBIRTH → periodic N/DDATA; honours a rebirth NCMD; injects seq-gap / alias-reuse / N/DDEATH on demand)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` (add the simulator container against the same TLS+auth Mosquitto)
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugLiveTests.cs` (category `LiveIntegration`, env-gated: birth→data→alias-resolve→OnDataChange; rebirth recovery; death→STALE; browser passive window asserts **no NCMD published by open/browse**; explicit `RequestRebirthAsync` node-vs-group enumeration)
### Steps
1. Build the simulator (encode via the same generated proto — validates encode/decode symmetry).
2. Write env-gated live tests covering the §3.6 matrix + browser passivity.
3. Offline skip proof:
```bash
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests # expect: Skipped without MQTT_FIXTURE_ENDPOINT
```
4. Deploy + run live:
```bash
lmxopcua-fix sync mqtt && lmxopcua-fix up mqtt sparkplug
export MQTT_FIXTURE_ENDPOINT=10.100.0.35:8883
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests --filter "Category=LiveIntegration" # expect: PASS
```
5. Commit: `git commit -am "test(mqtt): C# Sparkplug edge-node simulator + §3.6 live matrix"` (+ trailer).
---
## Task 26 (P2): Live `/run` verify Sparkplug — **P2 COMPLETE**
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `docs/plans/2026-07-15-driver-expansion-tracking.md` (mark MQTT/Sparkplug P1+P2 code-complete + live-verified)
- Modify: `CLAUDE.md` (if a driver-fleet or endpoint fact changed; propagate to `../scadaproj/CLAUDE.md` OtOpcUa entry per the cross-repo rule)
### Steps
1. On docker-dev (`:9200`): author an `Mqtt` driver (SparkplugB mode) against the simulator; browse the birth-driven picker, click **Request rebirth**, pick a metric, deploy.
2. Confirm live values + rediscover-on-DBIRTH + death→STALE via Client.CLI reads/subscribe.
3. Confirm the typed editor renders both modes (live-verify — Razor bugs pass unit tests).
4. Update tracking + (if needed) CLAUDE.md/scadaproj index.
5. Commit: `git commit -am "docs(mqtt): Sparkplug P2 live-verified; MQTT driver complete"` (+ trailer).
---
## Deferred / out of scope
- **Write-through (P3 in the design):** `IWritable` — Sparkplug NCMD/DCMD + plain publish, optimistic-Good + self-correct on echo (mirrors Galaxy fire-and-forget), `WriteIdempotent` default-off for non-idempotent Sparkplug commands, write-topic config. See design §3.7 + §10 (P3 row). `MqttDriver` ships **read/subscribe/discover only** in P1+P2 — its absence of `IWritable` means nodes materialize read-only.
- **`DataSet`/`Template` Sparkplug metrics** — unsupported v1 (skip + warn, or raw-JSON as String). §3.5.
- **`Bytes`/`File` metrics** beyond a base64/raw-String fallback. §3.5.
- **EMQX broker** — Mosquitto is the default fixture; EMQX is the documented alternative when a dashboard/built-in Sparkplug tooling helps (§9), not built here.
## Notes on ordering / parallelism
- **P2 is blocked on the P1 milestone (Task 14).** Every P2 task's `blockedBy` chain roots at Task 14 so plain MQTT ships and is live-verified before Sparkplug work begins — and MQTTnet-5/net10 is proven (Task 0) before any proto/Sparkplug effort.
- Genuine parallel pairs: 5‖6, 8‖10, 11‖12, 16‖17, 18‖19, 23‖24.
- DRY: reuse `EquipmentTagRefResolver<MqttTagDefinition>` (as Modbus/OpcUaClient do), the shared `JsonSerializerOptions`, and one `SparkplugCodec` for both decode (ingest) and encode (rebirth NCMD). YAGNI: no write-through plumbing, no DataSet/Template, no EMQX until a need lands.