Task 26 — the P2 milestone gate. Ran the live /run verification on an isolated
docker-dev rig (project otopcua-mqtt, ports 9210/4850-4851/14350, image built
from this branch) against the real Mosquitto TLS+auth broker and the C#
Sparkplug edge-node simulator on 10.100.0.35, and recorded the result.
The gate found three defects. Two are the same defect class the P1 gate found
twice — a hand-maintained AdminUI surface left behind by a driver-side feature —
and the third is a pre-existing cross-driver bug that only a Sparkplug flow
could surface.
1. CRITICAL, FIXED — MqttDriverForm still shipped its P1 Sparkplug PLACEHOLDER.
Switching Mode to SparkplugB rendered a "not available yet" notice and NO
Group ID field, so with Sparkplug ingest fully shipped there was still no
way to author a Sparkplug driver from the AdminUI at all. Sparkplug.GroupId
is the driver's entire subscription filter (spBv1.0/{GroupId}/#): blank ⇒
connected, Healthy, ingesting nothing. Now authors all five Sparkplug keys,
MERGES over the existing sub-object rather than replacing it (so a key a
newer driver adds inside it survives an older AdminUI), leaves it untouched
in Plain mode, and validates the group id as the topic segment it is.
Pinned by 8 MqttDriverFormModelTests cases, verified falsifiable — 8 RED
with the fix stubbed out.
2. PRE-EXISTING + CROSS-DRIVER, FIXED — every node label in the shared
DriverBrowseTree was an <a href="#">. In a Blazor Web App, blazor.web.js's
enhanced-navigation click interceptor resolves a bare "#" against
<base href="/">, so clicking ANY browse-tree node label navigated the whole
AdminUI to "/", tore down the circuit, and destroyed the hosting modal —
losing the browse session AND the tag selection. @onclick:preventDefault
does not help: it suppresses the browser's default action, not Blazor's own
interceptor. Labels are now <button type="button">. Dates to the component's
introduction (2026-05-28) and affects EVERY driver's picker; it surfaced
only now because choosing a rebirth scope is the first flow that requires
clicking a label rather than the ▶ toggle (always a button, always fine).
3. FIXED (mitigation) — the browse tree rendered exactly once, at open, and
never again, while OpenAsync returns as soon as the SUBSCRIBE is granted.
On Sparkplug that means it is almost always empty forever, because births
are never retained. Added a Refresh button that re-reads the root against
the SAME session (nothing reconnects, nothing observed is lost, the tag
selection is kept, only the armed rebirth scope is cleared).
Live gate result (all against the real broker + simulator, group OtOpcUaSim):
- driver authored end-to-end through the fixed MqttDriverForm; blob is
Mode:SparkplugB + Sparkplug.GroupId:OtOpcUaSim with enums as names
- browse: BROWSER OPEN chip, Sparkplug-only rebirth panel, tree renders
Group → EdgeNode → [Device] → Metric with the device folder Filler1 and
node-level metric leaves SIDE BY SIDE, and "Node Control/Rebirth" as ONE
leaf (the metric-name-is-not-a-topic-segment rule holding)
- rebirth: group scope names the group + the 32-node cap, Cancel published
NOTHING (simulator log unchanged); edge-node scope → "1 command published"
and the simulator logged "rebirth NCMD #1 received; republishing metadata";
group scope → "2 commands published", both edge nodes re-birthed; a device
and a metric both resolve UP to "edge node EdgeA"; the panel does NOT render
for a Plain MQTT device
- browse-commit wrote bindable tuples with no topic/address key —
{"groupId","edgeNodeId","metricName","deviceId"} for the device metric and
deviceId ABSENT (not blank) for the node-level one, datatypes inherited from
the birth (Int64 / Float)
- deployed (Sealed), and both nodes served live changing values through OPC UA
at the 2 s cadence — Good 0x00000000, no BadNodeIdUnknown
- death→STALE: stopping the simulator drove both nodes to 0x80000000 with an
empty value ~2 s later, and restarting it recovered them to Good on the new
birth (FillCount back to its birth-declared 1000, then counting)
- rediscovery fired exactly TWICE (once per authored scope: OtOpcUaSim/EdgeA
7 metrics, OtOpcUaSim/EdgeA/Filler1 3 metrics) and was then gated across
many subsequent births/rebirths — the anti-storm change gate working, and
EdgeB correctly filtered out as an unauthored scope
- tag editor: opens in SparkplugB (mode inference on reopen) with all four
descriptor fields populated; blank group → "A Sparkplug group ID is
required."; "Plant/1" rejected; "Node Control/Rebirth" ACCEPTED and saved
with the slash whole; dataType override persists and its key is ABSENT again
after selecting "(from birth certificate)"
Two things are NOT verified, and are recorded rather than claimed:
- Rediscovery is INERT in v3 and this is a platform gap, not an MQTT one:
nothing subscribes to IRediscoverable.OnRediscoveryNeeded and
DriverHostActor.HandleDiscoveredNodes hard-returns. A DBIRTH introducing a
metric does NOT change the OPC UA tree; redeploy. Verified from the driver's
log line only, exactly as far as it can be verified.
- A metric name containing "/" cannot be BROWSE-COMMITTED: the derived raw tag
Name is a RawPath segment and may not contain "/", so the spec-mandatory
"Node Control/Rebirth" is refused ("Row 3: Name must not contain '/'"). The
refusal is loud and all-or-nothing, never a silently mis-bound tag, and
Manual entry is the working path. Fixing it needs a name-sanitisation policy
with a collision answer, so it was recorded rather than guessed at.
Also left open + documented: an empty browse tree cannot arm a rebirth (the
scope comes from a tree click, and the session side already accepts a bare
{group}/{edgeNode} — only a UI path is missing); _canRebirth is captured at
browse-open; the tag editor injects the Plain-only payloadFormat key into a
Sparkplug blob (cosmetic — the factory routes on the tuple).
Suites: offline 1528 passed / 0 failed (581 Driver.Mqtt + 809 AdminUI + 138
Core.Abstractions); live 15/15 against the broker + simulator.
Docs: docs/drivers/Mqtt.md brought fully up to date for P2 (Sparkplug config
sub-object, both tag shapes, the ingest state machine, rediscovery, browse +
rebirth + Refresh, a Known-gaps table); Mqtt-Test-Fixture.md gains the simulator
and drops its "Sparkplug has no fixture" claims; infra/README.md §3 gains the
simulator row; CLAUDE.md gains the simulator endpoint facts; the tracking doc
marks MQTT/Sparkplug COMPLETE. docker-dev/docker-compose.mqtt.yml is the
isolated-rig overlay the gate ran on.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
Drivers
OtOpcUa is a multi-driver OPC UA server. The Core (ZB.MOM.WW.OtOpcUa.Core + Core.Abstractions + Server) owns the OPC UA stack, address space, session/security/subscription machinery, resilience pipeline, and namespace kinds (Equipment + SystemPlatform). Drivers plug in through capability interfaces defined in src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/:
IDriver— lifecycle (InitializeAsync,ReinitializeAsync,ShutdownAsync,GetHealth)IReadable/IWritable— one-shot reads and writesITagDiscovery— address-space enumerationISubscribable— driver-pushed data-change streamsIHostConnectivityProbe— per-host reachability eventsIPerCallHostResolver— multi-host drivers that route each call to a target endpoint at dispatch timeIAlarmSource— driver-emitted OPC UA A&C eventsIHistoryProvider— driver-side raw / processed / at-time / events HistoryRead (see HistoricalDataAccess.md)IRediscoverable— driver-initiated address-space rebuild notificationsIHistorianDataSource— server-side historian read backend registration (the HistorianGateway backend), distinct from the driver-sideIHistoryProviderHistoryRead path
Each driver opts into only the capabilities it supports. Every async capability call at the Server dispatch layer goes through CapabilityInvoker (Core/Resilience/CapabilityInvoker.cs), which wraps it in a Polly pipeline keyed on (DriverInstanceId, HostName, DriverCapability). The OTOPCUA0001 analyzer enforces the wrap at build time. Drivers themselves never depend on Polly; they just implement the capability interface and let the Core wrap it.
Driver type metadata is registered at startup in DriverTypeRegistry (src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs). The registry records each type's allowed namespace kinds (Equipment / SystemPlatform / Simulated), its JSON Schema for DriverConfig / DeviceConfig / TagConfig columns, and its stability tier per docs/v2/driver-stability.md.
Ground-truth driver list
| Driver | Project path | Tier | Wire / library | Capabilities | Notable quirk |
|---|---|---|---|---|---|
| Galaxy | Driver.Galaxy (+ .Browser, .Contracts) |
A | gRPC to the external mxaccessgw gateway (the gateway owns MXAccess COM + the Galaxy Repository SQL reader) |
IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IRediscoverable, IHostConnectivityProbe | In-process .NET 10 driver — the COM bitness constraint lives in the gateway's x86 net48 worker, not here. PR 7.2 retired the legacy in-process Galaxy.{Shared, Host, Proxy} + named-pipe Windows service. Native MxAccess alarms work end-to-end |
| Modbus TCP | Driver.Modbus |
A | NModbus-derived in-house client | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Polled subscriptions via the shared PollGroupEngine. DL205 PLCs are covered by AddressFormat=DL205 (octal V/X/Y/C/T/CT translation) — no separate driver |
| Siemens S7 | Driver.S7 |
A | S7netplus | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Single S7netplus Plc instance per PLC serialized with SemaphoreSlim — the S7 CPU's comm mailbox is scanned at most once per cycle, so parallel reads don't help |
| AB CIP | Driver.AbCip |
A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | ControlLogix / CompactLogix. Tag discovery uses the @tags walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader |
| AB Legacy | Driver.AbLegacy |
A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (N7:0, F8:0) — no symbol table, tag list is user-authored in the config DB |
| TwinCAT | Driver.TwinCAT |
B | Beckhoff TwinCAT.Ads (TcAdsClient) |
IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable | The only native-notification driver outside Galaxy — ADS delivers ValueChangedCallback events the driver forwards straight to ISubscribable.OnDataChange without polling. Symbol tree uploaded via SymbolLoaderFactory |
| FOCAS | Driver.FOCAS |
A | Pure-managed FocasWireClient — FOCAS/2 Ethernet binary protocol on TCP:8193, inlined into the driver assembly |
IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | IWritable is implemented but read-only by design — WriteAsync returns BadNotWritable for every point. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map. Previously Tier-C (Host + P/Invoke + shim DLL); retired in the 2026-04-24 migration when the managed wire client landed |
| OPC UA Client | Driver.OpcUaClient |
B | OPCFoundation Opc.Ua.Client |
IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver — the only driver implementing driver-side IHistoryProvider (forwards HistoryRead to the upstream server). Opens a single Session against a remote OPC UA server and re-exposes its address space. Owns its own ApplicationConfiguration (distinct from Client.Shared) because it's always-on with keep-alive + TransferSubscriptions across SDK reconnect, not an interactive CLI |
| MQTT | Driver.Mqtt (+ .Browser, .Contracts) |
A | MQTTnet 5 | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | Push, not poll, and read-only — the broker delivers PUBLISH frames the driver forwards straight to ISubscribable.OnDataChange; IReadable serves the last retained/received value from cache, and there is no IWritable (nothing publishes back). Subscriptions are indexed by topic filter, not by topic, so wildcard tags survive a reconnect. A rejected CONNACK throws MqttConnectRejectedException: unrecoverable codes → Faulted + supervisor stop, transient → retry. Sparkplug B is P2 (mode: SparkplugB is a placeholder) |
| Historian.Gateway | Driver.Historian.Gateway |
— | ZB.MOM.WW.HistorianGateway.Client gRPC (historian_gateway.v1) |
IHistorianDataSource (server-side read backend) + alarm SendEvent writer + WriteLiveValues recorder + IHistorianProvisioning |
Not a tag driver — the sole historian backend. Registers GatewayHistorianDataSource : IHistorianDataSource for HistoryRead and serves alarm-write + continuous historization through the gateway. No IDriver/ITagDiscovery surface. (The retired Wonderware sidecar backend it replaced is documented at Historian.Wonderware.md.) |
Per-driver documentation
-
Galaxy has its own docs in this folder because the gRPC-to-gateway architecture + MXAccess rules (owned by the gateway) + Galaxy Repository SQL + Historian + runtime probe manager don't fit a single table row:
- Galaxy.md — gateway gRPC bridge, hierarchy source, runtime probes
- Galaxy-Repository.md — ZB SQL reader,
LocalPlatformscope filter, change detection (v1 archive)
-
FOCAS has a short getting-started doc because the backend-selection env var + alarm projection opt-in need explaining up front:
- FOCAS.md — deployment, config, capability surface, alarm projection, troubleshooting
-
Modbus TCP, AB CIP, AB Legacy, Siemens S7, TwinCAT, OPC UA Client, and MQTT each have a per-driver overview page:
- Modbus.md — in-process Modbus-TCP driver: address formats, polled subscription model, DL205 octal mapping
- AbCip.md — AB CIP / EtherNet-IP driver (ControlLogix / CompactLogix / Micro800 / GuardLogix): tag discovery, UDT resolution, alarm source
- AbLegacy.md — AB Legacy PCCC driver (SLC 500 / MicroLogix / PLC-5): file-based addressing, user-authored tag list
- S7.md — Siemens S7 driver (S7-300/400/1200/1500 + S7-200): getting started, config, data-block addressing, serialized single-connection model
- TwinCAT.md — Beckhoff TwinCAT (ADS) driver: getting started, native-notification subscription, symbol-tree upload
- OpcUaClient.md — OPC UA Client (gateway/aggregation) driver: remote-server session, driver-side HistoryRead forwarding, reconnect behaviour
- Mqtt.md — MQTT broker-subscribe driver: broker/TLS/auth config, topic + JSON-path tag binding, retained-value seeding,
#-observation browser, CONNACK-rejection semantics
-
Historian.Gateway (server-side historian backend, not a tag driver) is documented in the main guide:
- ../Historian.md — HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (
SendEvent), continuous historization (WriteLiveValues),EnsureTagsprovisioning, config keys, deployment prerequisites. (The retired Wonderware sidecar backend it replaced: Historian.Wonderware.md.)
- ../Historian.md — HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (
-
The full per-field spec (capability surface, config schema, addressing, data-type maps, connection settings, quirks for every driver) lives in docs/v2/driver-specs.md. The overview pages above are the short path; that file is the authoritative per-driver reference.
Test-fixture coverage maps
Each driver has a dedicated fixture doc that lays out what the integration / unit harness actually covers vs. what's trusted from field deployments. Read the relevant one before claiming "green suite = production-ready" for a driver.
- AB CIP — Dockerized
ab_server(multi-stage build from libplctag source); atomic-read smoke across 4 families; UDT / ALMD / family quirks unit-only - Modbus — Dockerized
pymodbus+ per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped - Siemens S7 — Dockerized
python-snap7server; DB/MB read + write round-trip verified end-to-end on:1102 - AB Legacy — Dockerized
ab_serverPCCC mode across SLC500 / MicroLogix / PLC-5 profiles (task #224); N/F/L-file round-trip verified end-to-end./1,0cip-path required for the Docker fixture; real hardware uses empty. Residual gap: bit-file writes (B3:0/5) still surface BadState — real HW / RSEmulate 500 for those - TwinCAT — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via
FakeTwinCATClientwith native-notification harness - FOCAS — no integration fixture, unit-only via
FakeFocasClient; Tier C out-of-process isolation scoped but not shipped - OPC UA Client — Dockerized
opc-plcintegration suite (task #215): real Secure Channel + Session, read + subscribe verified end-to-end; write not yet exercised in the integration suite; exhaustive capability matrix (reconnect, failover, cert-auth, history, alarms) via unit suite with mockedSession - MQTT — Dockerized
eclipse-mosquittowith TLS + real auth on both listeners (no anonymous fallback) + a JSON-publisher sidecar; env-gated live suite (7 tests) covers TLS/auth connect, CA pinning both ways, subscribe→value, retained seeding, and wrong-password rejection. Sparkplug B has no fixture yet (P2) - Galaxy — richest harness: gateway E2E + ZB SQL live-smoke + MXAccess opt-in (v1 archive)
Related cross-driver docs
- HistoricalDataAccess.md —
IHistoryProviderdispatch, aggregate mapping, continuation points. The OPC UA Client driver is the only driver that implements driver-sideIHistoryProvider(it forwards HistoryRead to the upstream server); the AVEVA Historian path is served server-side by the HistorianGateway-backedIHistorianDataSourceinstead. Other drivers do not implement the interface and returnBadHistoryOperationUnsupported. - AlarmTracking.md —
IAlarmSourceevent model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, TwinCAT, and MQTT have no alarm source. - Subscriptions.md — how the Server multiplexes subscriptions onto
ISubscribable.OnDataChange. - docs/v2/driver-stability.md — tier system (A / B / C), shared
CapabilityPolicydefaults per tier × capability,MemoryTrackinghybrid formula, and process-level recycle rules. - docs/v2/plan.md — authoritative vision, architecture decisions, migration strategy.