Files
lmxopcua/docs
Joseph Doherty d5bd4226ee docs(mqtt): Sparkplug P2 live-verified; MQTT driver complete
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
2026-07-25 01:11:56 -04:00
..

OtOpcUa documentation

Two tiers of documentation live here:

  • Current reference at the top level (docs/*.md) — describes what's shipped today. Start here for operator + integrator reference.
  • Implementation history + design notes at docs/v2/*.md — the authoritative plan + decision log the current reference is built from. Start here when you need the why behind an architectural choice, or when a top-level doc says "see plan.md § X".

The project was originally called LmxOpcUa (a single-driver Galaxy/MXAccess OPC UA server) and has since become OtOpcUa, a multi-driver OPC UA server platform. Any lingering LmxOpcUa-string in a path you see in docs is a deliberate residual (executable name lmxopcua-cli, client PKI folder {LocalAppData}/LmxOpcUaClient/) — fixing those requires migration shims + is tracked as follow-ups.

Platform overview

v2 (2026-05-26): the separate OtOpcUa.Server + OtOpcUa.Admin services fused into a single role-gated OtOpcUa.Host binary, joined by an Akka.NET cluster. See v2 design for the architectural decision.

  • Core owns shared abstractions (driver capability contracts, scripting, virtual tags, alarm historian).
  • Drivers plug in via capability interfaces in ZB.MOM.WW.OtOpcUa.Core.Abstractions: IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IAlarmSource, IHistoryProvider, IPerCallHostResolver. Each driver opts into whichever it supports.
  • Host (src/Server/ZB.MOM.WW.OtOpcUa.Host) is the single fused binary (.NET 10, AnyCPU). OTOPCUA_ROLES env decides what to mount: admin (Blazor + control-plane singletons), driver (OPC UA endpoint + per-node actors), or both. See ServiceHosting.md.
  • Cluster + ControlPlane + Runtime + AdminUI + Security sit between Core and Host. The cluster glues per-node actors into one logical fleet; the control-plane singletons (deploy coordinator, audit writer, redundancy state) live on the admin role-leader. See Redundancy.md.
  • The Galaxy driver still reaches MXAccess via gRPC to a separately-installed mxaccessgw sidecar (sibling repo).

Where to find what

Architecture + data-path reference

Doc Covers
OpcUaServer.md Top-level server architecture — Core, driver dispatch, Config DB, generations
AddressSpace.md GenericDriverNodeManager + ITagDiscovery + IAddressSpaceBuilder
ReadWriteOperations.md OPC UA Read/Write → CapabilityInvokerIReadable/IWritable
DriverLifecycle.md Server-side driver lifecycle + infrastructure contracts (IDriverFactory, IDriverProbe, IDriverSupervisor, IDriverHealthPublisher, IDriverConfigEditor, IHistorianDataSource) + the Commons library
Subscriptions.md Monitored items → ISubscribable + per-driver subscription refcount (v1 archive)
AlarmTracking.md IAlarmSource + AlarmSurfaceInvoker + OPC UA alarm conditions — native Galaxy alarms end-to-end (live)
AlarmTracking.md Original alarm-tracking write-up (v1 archive)
AlarmHistorian.md Core.AlarmHistorian store-and-forward sink — LocalDbStoreAndForwardSink, the replicated alarm_sf_events buffer + Primary-only drain, IAlarmHistorianWriter, dead-letter/retry/eviction
DataTypeMapping.md Per-driver DriverAttributeInfo → OPC UA variable types (v1 archive — live mapping is in src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs)
IncrementalSync.md Address-space rebuild on redeploy + sp_ComputeGenerationDiff
HistoricalDataAccess.md IHistoryProvider as a per-driver optional capability (v1 archive)
VirtualTags.md Core.Scripting + Core.VirtualTags — Roslyn script sandbox, engine, dispatch alongside driver tags
ScriptEditor.md Monaco script editor — Roslyn-backed IntelliSense (completions, diagnostics, hover, tag-path) for C# virtual-tag scripts; AdminUI/ScriptAnalysis/ backend
ScriptedAlarms.md Core.ScriptedAlarms — script-predicate IAlarmSource + Part 9 state machine

One Core subsystem is shipped without a dedicated top-level doc; see the section in the linked doc:

Project See
Analyzers (Roslyn OTOPCUA0001) security.md § OTOPCUA0001 Analyzer

Drivers

Doc Covers
drivers/README.md Index of the eight shipped drivers + capability matrix
drivers/Galaxy.md Galaxy driver — in-process gRPC client to the mxaccessgw sidecar
v1/drivers/Galaxy-Repository.md Galaxy-specific discovery via the ZB SQL database (v1 archive — the gateway owns this path now)

For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics, see v2/driver-specs.md.

Operational

Doc Covers
Configuration.md Live appsettings + environment-variable reference (current state)
Configuration.md appsettings bootstrap + Config DB + Admin UI draft/publish (v1 archive — OTOPCUA_GALAXY_* env vars now live in mxaccessgw config)
Uns.md The global /uns master tree — manage Area/Line/Equipment/Tag/VirtualTag fleet-wide; replaces the per-cluster UNS/Equipment/Tags tabs
security.md Transport security profiles, LDAP auth, ACL trie, role grants, OTOPCUA0001 analyzer
Redundancy.md RedundancyCoordinator, ServiceLevelCalculator, apply-lease, Prometheus metrics
Reservations.md Fleet-wide ZTag / SAPID external-ID reservations — publish-time claim, release flow
ServiceHosting.md Single fused OtOpcUa.Host binary install/uninstall with OTOPCUA_ROLES gating; the historian backend is the external HistorianGateway
StatusDashboard.md Pointer — superseded by v2/admin-ui.md

Client tooling

Doc Covers
Client.CLI.md otopcua-cli — OPC UA command-line client
Client.UI.md Avalonia desktop client
DriverClis.md Driver test-client CLIs — index + shared commands
Driver.Modbus.Cli.md otopcua-modbus-cli — Modbus-TCP
Driver.AbCip.Cli.md otopcua-abcip-cli — ControlLogix / CompactLogix / Micro800 / GuardLogix
Driver.AbLegacy.Cli.md otopcua-ablegacy-cli — SLC / MicroLogix / PLC-5 (PCCC)
Driver.S7.Cli.md otopcua-s7-cli — Siemens S7-300 / S7-400 / S7-1200 / S7-1500
Driver.TwinCAT.Cli.md otopcua-twincat-cli — Beckhoff TwinCAT 2/3 ADS
Driver.FOCAS.Cli.md otopcua-focas-cli — Fanuc FOCAS/2 CNC

Requirements

Doc Covers
reqs/HighLevelReqs.md HLRs — numbered system-level requirements
reqs/OpcUaServerReqs.md OPC UA server-layer reqs
v1/reqs/ServiceHostReqs.md Per-process hosting reqs (v1 archive — only OtOpcUa server hosting remains in scope post-PR-7.2)
reqs/ClientRequirements.md Client CLI + UI reqs
v1/reqs/GalaxyRepositoryReqs.md Galaxy-scoped repository reqs (v1 archive — owned by mxaccessgw today)
v1/reqs/MxAccessClientReqs.md Galaxy-scoped MXAccess reqs (v1 archive — owned by mxaccessgw today)
reqs/StatusDashboardReqs.md Pointer — superseded by Admin UI

Implementation history (docs/v2/)

Design decisions + phase plans + execution notes. Load-bearing cross-references from the top-level docs:

v1 archive

The v1 in-process MXAccess architecture (Galaxy.Host + Galaxy.Proxy + Galaxy.Shared, .NET 4.8 x86 COM, the OtOpcUaGalaxyHost Windows service) was retired in PR 7.2 (2026-04-30, commit ae7106d). Docs that described that shape are kept under v1/ as historical record — see v1/README.md for the index.