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
55 KiB
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 0–14 (a complete shippable milestone); P2 (Sparkplug B ingest) = Tasks 15–26 (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
MqttDriverOptionsor a tag config — factory, probe, browser, and the AdminUI driver-config page + probe DTO — MUST share aJsonSerializerOptionscarryingnew JsonStringEnumConverter()+PropertyNameCaseInsensitive=true+UnmappedMemberHandling=Skip.MqttMode,MqttPayloadFormat,protocolVersionare enums; serialize them as names. MirrorOpcUaClientDriverFactoryExtensions. - 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/MqttDriverBrowserconstructors touch no network; all connects happen inInitializeAsync/OpenAsync(the universal-browserCanBrowsethrowaway-instance pattern depends on this). - Secrets from env: broker
passwordis blank in committed JSON, supplied via env (mirrorServerHistorian__ApiKey). Never commit or log creds. - Broker security — never ship anonymous/public-broker defaults: default
useTls=true, real auth.allowUntrustedServerCertificate+caCertificatePathmirror 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 constantDriverTypeNames.Mqtt; grep to enforce (heed theModbusTcp/Modbusmismatch lesson). - Live-verify discipline: Razor binding + deploy-inertness bugs pass unit tests. Every picker/editor/deploy path gets a docker-dev
/runlive-verify (Tasks 14 + 26). - New-project csproj:
net10.0,Nullable+ImplicitUsingsenabled,TreatWarningsAsErrors=trueopted 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 — referencesMQTTnetonly to prove the graph restores; the transport ref is removed in Task 1,.Contractsis 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
- Confirm on NuGet the exact latest MQTTnet v5 version carrying a
net10.0TFM; pin that exact version inDirectory.Packages.props(alphabetical position nearMessagePack). - Scaffold the
.Contractscsproj (net10.0, nullable, implicit usings, TWAE) with a single<PackageReference Include="MQTTnet" />(noVersion— central management supplies it). Add toslnx. - 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.
- Prove a clean restore + build:
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:
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 —.Contractsis transport-free; reference onlyCore.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/plainsub-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)
- Write the failing test — options round-trip enum-as-name:
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");
}
}
- Run — expect FAIL (types don't exist):
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests # RED
- Implement the enums +
MqttDriverOptions(with nestedMqttSparkplugOptions/MqttPlainOptions) matching §5.1 defaults (useTls=true,connectTimeoutSeconds=15,reconnectMin/MaxBackoffSeconds=1/30). Wire the test csproj intoslnx. - Run — expect PASS.
- 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), mirrorsModbusEquipmentTagParser/ModbusTagDefinitionFactory) - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs
Steps (TDD)
- Failing tests — parse a plain TagConfig blob, reject a typo'd enum (strict), and reject a wildcard topic:
[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();
- Run — expect FAIL.
- Implement: a leading
{marks a TagConfig blob; useTagConfigJson.TryReadEnumStrictforpayloadFormat/dataType(typo →false→BadNodeIdUnknownupstream).def.Name = reference.Inspectreturns 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). - Run — expect PASS.
- 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 fromMqttDriverOptionsincl. TLS + CA-pin + credentials;ConnectAsync(ct)underconnectTimeoutSecondslinked-CTS) - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs - Modify:
ZB.MOM.WW.OtOpcUa.slnx
Steps (TDD)
- 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:
[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();
}
- Run — expect FAIL.
- Implement
MqttConnection: staticBuildClientOptions(opts, clientIdSuffix)(host/port, protocol version, clientId + optional suffix, keep-alive, clean-session, credentials, TLS withAllowUntrustedServerCertificate→ custom cert validator,CaCertificatePath→ chain pin).ConnectAsync(ct)linksctwith aconnectTimeoutSecondsCTS. Ctor stores options only — connection-free. - Run — expect PASS.
- 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 backoffmin→max; onDisconnectedAsyncschedule reconnect; on reconnect fire aReconnectedcallback so the driver re-subscribes; exposeState= 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)
- Failing tests — backoff schedule is bounded + monotone-to-cap, and a
Reconnectedevent drives a re-subscribe callback. Test the backoff calculator as a pure function (no live broker):
[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);
}
- Run — expect FAIL.
- Implement:
NextBackoff(attempt, min, max)pure; a reconnect worker that loops on disconnect with backoff, honourscleanSession/session-expiry, re-subscribes via theReconnectedcallback (idempotent insurance even for persistent sessions), and (Sparkplug, P2) re-requests rebirth.Statetransitions Connected↔Reconnecting; Faulted only on unrecoverable config. Never block the MQTTnet dispatcher thread. - Run — expect PASS.
- 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)
- Failing test — unseen reference returns a per-ref
GoodNoData/uncertain snapshot (never throws the batch); a seeded ref returns last value:
[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);
}
- Run — expect FAIL.
- Implement
LastValueCache(concurrent dict);MqttDriver.ReadAsync(Task 7) returnsreferences.Select(cache.Read)— batch never throws, per-refStatusCodecarries "not yet observed". - Run — expect PASS.
- 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(registersFullReference → MqttTagDefinitionvia the sharedEquipmentTagRefResolver<MqttTagDefinition>; dedupes/coalesces topics; on message: match topic → authored tag(s), extract value atjsonPath/raw/scalar, raiseOnDataChange; seed from retained message on subscribe) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs(exposeSubscribeAsync(filters, ct)under a bounded deadline + a message-received event) - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs
Steps (TDD)
- Failing test — feed a synthetic received message through the manager (no live broker); assert routing + JSONPath extraction fire
OnDataChangewith the rightFullReference:
[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();
}
- Run — expect FAIL.
- Implement using
EquipmentTagRefResolver<MqttTagDefinition>(parse-once cache, as Modbus does).HandleMessagematches topic → tag(s), extracts (Json→JSONPath token→typed value; Raw→bytes-as-string; Scalar→parse), updatesLastValueCache, raisesOnDataChange. Retained-flagged messages seed initial value.SubscribeAsyncreturns anISubscriptionHandlewhoseDiagnosticIdnames mode+filter; completes under a bounded SUBACK deadline (SUBACK failure → per-ref Bad, not hang). - Run — expect PASS.
- 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; composesMqttConnection+MqttSubscriptionManager+LastValueCache) - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs
Steps (TDD)
- Failing test —
DiscoverAsyncstreams only authored tags into a capturingIAddressSpaceBuilder; plain-modeRediscoverPolicy == Once;SupportsOnlineDiscovery == false:
[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();
}
- Run — expect FAIL.
- Implement:
InitializeAsyncdeserializes options (sharedJsonSerializerOptions), builds + connectsMqttConnection, subscribes the mode-appropriate filter.ReinitializeAsyncapplies deltas in place (never crash → Faulted).ShutdownAsyncdisconnects/disposes.GetHealth= Connected/Reconnecting/Faulted + last-message-age.GetMemoryFootprint= caches;FlushOptionalCachesAsync= no-op (birth/alias are correctness state — forbidden to flush; last-value backsIReadable).DiscoverAsyncreplays authored tags only;RediscoverPolicy => Once(plain).IRediscoverable.OnRediscoveryNeedednever fires in plain mode. - Run — expect PASS.
- 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 — mirrorsModbusDriverProbe) - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs
Steps (TDD)
- Failing test —
DriverTypeis"Mqtt"; a dead endpoint yields a failed (not thrown) probe result within the deadline:
[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();
}
- Run — expect FAIL.
- Implement; CONNACK-accepted is the MQTT "device is answering" proof. Use the shared
JsonSerializerOptions(enum-as-name). - Run — expect PASS.
- 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", sharedJsonSerializerOptions,Register(registry, loggerFactory)— mirrorOpcUaClientDriverFactoryExtensions) - Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs(addpublic const string Mqtt = "Mqtt";+ append to theAlllist) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs(addDriver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory);inRegister(...)near line 146; addusing MqttProbe = Driver.Mqtt.MqttDriverProbe;+services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());inAddOtOpcUaDriverProbesnear line 124) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj(ProjectReference the.Driverassembly) - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs
Steps (TDD)
- Failing test —
Registerbinds the"Mqtt"type, and the factory builds anMqttDriverfrom JSON with string enums:
[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);
- Run — expect FAIL.
- Implement + wire both host sites. Build the whole solution to confirm registration compiles:
dotnet build ZB.MOM.WW.OtOpcUa.slnx # expect: Build succeeded
- Run — expect PASS.
- 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";OpenAsyncconnects with a{clientId}-browse-{guid8}suffix under a clamped 5–30 s budget; subscribes#/{topicPrefix}#; returnsMqttBrowseSession; publishes nothing) - Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs(IBrowseSessionover a thread-safe accumulating observed tree;RootAsync/ExpandAsyncsplit topic segments on/, leaf =Kind=Leaf;AttributesAsync= synthetic attribute w/ inferred type + last payload snippet;DisposeAsyncdisconnects best-effort) - Modify:
ZB.MOM.WW.OtOpcUa.slnx - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs
Steps (TDD)
- Failing test — feed observed topics into the session's accumulating tree (test seam, no live broker); assert
RootAsync/ExpandAsyncbuild the segment tree and no publish occurs on any browse call:
[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);
}
- Run — expect FAIL.
- 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). - Run — expect PASS.
- 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(addservices.AddSingleton<IDriverBrowser, MqttDriverBrowser>();alongside the existing two near line 75–76) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj(ProjectReference.Browser)
Steps
- Add the registration + project reference. Registering for
DriverType="Mqtt"overrides the universal fallback (BrowserSessionServiceresolves bespoke-first). - Build:
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI— expect success. - 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 preservedJsonObjectkey bag;FromJson/ToJson/Validate, preserves unknown keys incl. history keys; carries aModefield selecting sub-shape — P1 implements PlainValidate, 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-sideValidate()) - Create:
tests/Server/.../MqttTagConfigModelTests.cs(co-locate with the existing AdminUI test project — verify path withfind tests/Server -name "*TagConfigModel*Tests.cs")
Steps (TDD)
- Failing test — round-trip preserves unknown/history keys; plain
Validaterequires concrete topic + jsonPath-when-Json; re-derivesFullName:
[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();
- Run — expect FAIL.
- Implement the model (mirror
OpcUaClientTagConfigModel);ToJsonwrites PascalCaseFullNamere-derived from descriptor ({topic}#{jsonPath}plain). Build the.razor. Register both map entries. - Run — expect PASS +
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI. - 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-mosquittowith auth + TLS on:8883, plain:1883for smoke only; a JSON-publisher container emittingretain=trueJSON on a few topics —mosquitto_publoop orpaho-mqttscript;project=lmxopcualabel 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(categoryLiveIntegration, gated onMQTT_FIXTURE_ENDPOINT— skips clean when unset → macOS-offline-safe) - Modify:
ZB.MOM.WW.OtOpcUa.slnx
Steps
- Write the compose with auth + TLS (never anonymous). Add the JSON publisher with
retain=true. - Write env-gated tests: connect/TLS/auth; plain subscribe + retained-read seed; unauthored-topic silence.
[SkippableFact]readingMQTT_FIXTURE_ENDPOINT. - Confirm offline skip:
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests # expect: all Skipped (no env var)
- Deploy the fixture to the docker host and run live (design §9):
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
- 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
- On docker-dev (
:9200, login disabled — run it yourself, do not defer): author anMqttdriver (Plain mode) + a tag via the/unspicker (bespoke#-observation browser) or the typed editor; deploy. - Confirm via Client.CLI the node carries live broker values:
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>"
- Confirm the typed editor renders (Razor binding bugs pass unit tests — live-verify is mandatory). Update CLAUDE.md + tracking doc.
- 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 Tahusparkplug_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(addGoogle.Protobuf+ build-timeGrpc.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)
- Failing test — the generated
Payload/Metrictypes exist and round-trip a hand-built payload:
[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);
}
- Run — expect FAIL (no generated types).
- 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:
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: proto compiles, 0 warnings
- Run — expect PASS.
- 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(decodePayload/Metricfrom wire bytes → a driver-side struct; encode NCMD deferred toRebirthRequesterTask 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)
- Failing test — decode a golden NBIRTH: seq + metric name/alias/datatype/value survive:
[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);
}
- Run — expect FAIL.
- Implement
SparkplugCodec.Decode; generate the golden vectors in a one-off[Fact(Skip="generator")]or a small helper, commit the.binfiles. - Run — expect PASS.
- 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/formatspBv1.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)
- Failing tests — topic parse extracts group/type/node/device; datatype map widens Int8→Int16 and marks DataSet unsupported:
[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);
- Run — expect FAIL.
- Implement.
- Run — expect PASS.
- 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)
- Failing tests — the two load-bearing invariants:
[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
}
- Run — expect FAIL.
- Implement —
RebuildFromBirthreplaces the whole map.BirthCachekeeps the metric catalog + last value keyed by name. - Run — expect PASS.
- 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-nodeseq0–255 wrap gap detection;bdSeqties 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)
- Failing tests — contiguous ok, wrap ok, gap detected:
[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
}
- Run — expect FAIL.
- Implement (next-expected =
(last+1) & 0xFF);bdSeqcompare helper. - Run — expect PASS.
- 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 writingNode Control/Rebirth = truetospBv1.0/{group}/NCMD/{node}; publish under a bounded deadline) - Create:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/RebirthRequesterTests.cs
Steps (TDD)
- Failing test — encoded NCMD carries the
Node Control/Rebirth=true metric and targets the right topic:
[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));
}
- Run — expect FAIL.
- Implement (encode path via the generated proto).
- Run — expect PASS.
- 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 16–20 into the driver) Files:
- Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs(routes decoded messages: N/DBIRTH → rebuildAliasTable+BirthCache; N/DDATA → resolve alias→name → map to authoredFullReferenceby (group,node,device,metricName) →OnDataChange; N/DDEATH → emit STALE/Bad snapshots; seq-gap / unknown-alias / data-before-birth →RebirthRequestergated onrequestRebirthOnGap; STATE/primary-host handling; late-join rebirth on connect) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs(Sparkplug-mode subscribespBv1.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)
- Failing tests — the §3.6 matrix, driven through the ingestor with synthetic decoded messages (no live broker):
[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
}
- Run — expect FAIL.
- Implement the ingestor holding the §3.6 invariants. Route via
EquipmentTagRefResolverkeyed by(group,node,device,metricName). - Run — expect PASS (all matrix cases).
- 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 => UntilStablein Sparkplug mode — authored tags' datatypes fill in as births arrive;DiscoverAsyncre-streams authored tags with resolved datatypes fromBirthCache; fireOnRediscoveryNeededon 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)
- Failing tests — Sparkplug policy is
UntilStable; a new DBIRTH firesOnRediscoveryNeeded; a tag authored before its birth picks up the birth datatype:
[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();
}
- Run — expect FAIL.
- Implement.
- Run — expect PASS.
- 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 metricAttributeInfo{Name, DriverDataType from birth, IsArray, SecurityClass};RequestRebirthAsync(scope)— the only publishing session member, scoped to selected group/edge-node, via theRebirthRequesterpath) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs(Sparkplug discovery filterspBv1.0/{groupId}/#;OpenAsyncstill publishes nothing) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs(dispatch the MQTT-specificRequestRebirthAsyncfor MQTT sessions, gated by the sameDriverOperatorpolicy that gates the picker; Info-log the target scope) - Modify:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs
Steps (TDD)
- Failing tests — birth-driven tree fills;
OpenAsync/RootAsync/ExpandAsyncpublish nothing;RequestRebirthAsyncpublishes exactly one scoped NCMD:
[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);
}
- Run — expect FAIL.
- Implement.
RequestRebirthAsyncis never fired byOpenAsync/RootAsync/ExpandAsync— only on explicit operator click. - Run — expect PASS +
dotnet buildthe AdminUI. - 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(SparkplugValidate:groupId/edgeNodeId/metricNamerequired,deviceIdoptional,dataTypea known Sparkplug type;ToJsonre-derivesFullName={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)
- Failing tests — Sparkplug validation + FullName derivation:
[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");
- Run — expect FAIL.
- Implement.
- Run — expect PASS + AdminUI build.
- 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(categoryLiveIntegration, env-gated: birth→data→alias-resolve→OnDataChange; rebirth recovery; death→STALE; browser passive window asserts no NCMD published by open/browse; explicitRequestRebirthAsyncnode-vs-group enumeration)
Steps
- Build the simulator (encode via the same generated proto — validates encode/decode symmetry).
- Write env-gated live tests covering the §3.6 matrix + browser passivity.
- Offline skip proof:
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests # expect: Skipped without MQTT_FIXTURE_ENDPOINT
- Deploy + run live:
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
- 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.mdOtOpcUa entry per the cross-repo rule)
Steps
- On docker-dev (
:9200): author anMqttdriver (SparkplugB mode) against the simulator; browse the birth-driven picker, click Request rebirth, pick a metric, deploy. - Confirm live values + rediscover-on-DBIRTH + death→STALE via Client.CLI reads/subscribe.
- Confirm the typed editor renders both modes (live-verify — Razor bugs pass unit tests).
- Update tracking + (if needed) CLAUDE.md/scadaproj index.
- 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),WriteIdempotentdefault-off for non-idempotent Sparkplug commands, write-topic config. See design §3.7 + §10 (P3 row).MqttDriverships read/subscribe/discover only in P1+P2 — its absence ofIWritablemeans nodes materialize read-only. DataSet/TemplateSparkplug metrics — unsupported v1 (skip + warn, or raw-JSON as String). §3.5.Bytes/Filemetrics 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
blockedBychain 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 sharedJsonSerializerOptions, and oneSparkplugCodecfor both decode (ingest) and encode (rebirth NCMD). YAGNI: no write-through plumbing, no DataSet/Template, no EMQX until a need lands.