Files
lmxopcua/docs/drivers
Joseph Doherty 99c13e4459 fix(opcuaclient): #670 live-gate follow-ups — historize the outage Bad, drop late replays
F1: the driver's outage BadNoCommunication carried the last Good sample's
source timestamp. The recorder historizes SourceTimestampUtc ??
ServerTimestampUtc, so the Bad landed on the Good row's (TagId,
SourceTimeUtc) key and the insert-if-absent historian dropped it - no
outage marker until the host's watchdog marker ~95 s in, none at all for
a short blip. Now stamped with the detection time (source + server), as
the host's comms-loss fan does.

F2: after a same-session recovery the SDK delivered a missed notification
seconds late with an older source time and the node regressed. A
notification older than the newest Good upstream source time for its ref
(notification or recovery read) is dropped and counted
(stale_notification_dropped); equal timestamps pass; the outage Bad never
moves the comparison; a re-initialise resets it.
2026-10-11 09:11:34 -04:00
..

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 the two v3 OPC UA namespaces (Raw + UNS — see V3NodeIds / AddressSpaceRealm). 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 writes
  • ITagDiscovery — address-space enumeration
  • ISubscribable — driver-pushed data-change streams
  • IHostConnectivityProbe — per-host reachability events
  • IPerCallHostResolver — multi-host drivers that route each call to a target endpoint at dispatch time
  • IAlarmSource — driver-emitted OPC UA A&C events
  • IRediscoverable — driver-initiated address-space rebuild notifications
  • ISkippedTagReporter — authored tags the driver could not build into its poll table (see Skipped tags below)
  • IHistorianDataSource — server-side historian read backend registration (the selected ServerHistorian:Backend — HistorianGateway or SQL Server) (drivers carry no history capability; Gitea #655)

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 factories are registered at startup in DriverFactoryRegistry (src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs) — all 12 in one place, src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:147-164. Type names are the DriverTypeNames constants (src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs), guarded bidirectionally by DriverTypeNamesGuardTests.

⚠️ DriverTypeRegistry / DriverTypeMetadata were DELETED (Gitea #522, 2026-07-30). They were vestigial — no production code constructed or read them, only their own unit tests did — and AllowedNamespaceKinds had already been orphaned by the retirement of the v3 Namespace entity. Older docs and plans that name them as the driver-metadata or tier authority describe something that no longer exists.

Stability tier never came from that registry anyway: it is the DriverTier tier = DriverTier.A parameter on DriverFactoryRegistry.cs:46, and no factory in src/Drivers/ passes a tier, so every shipped driver runs Tier A. The Tier-C recycle protections that docs/v2/driver-stability.md describes — MemoryTracking, MemoryRecycle, ScheduledRecycleScheduler, IDriverSupervisor — are gone, not merely dormant: they required an out-of-process driver Host that no longer exists anywhere (Galaxy is gRPC-to-mxaccessgw, FOCAS is in-process), IDriverSupervisor had no implementations, and the trackers were only ever constructed in tests. The Tier column below is therefore uniformly A, and the tier's remaining effect is DriverResilienceOptions.GetTierDefaults — the per-capability timeout / retry / breaker policy set, which is live.

Skipped tags

Every driver builds its RawPath → definition table at InitializeAsync and tolerates a per-tag miss — an unmappable TagConfig, an unparseable address, a capability-matrix rejection, a TagConfig naming no readable id. The tag is logged, skipped, never enters the poll table, and every read of it returns BadNodeIdUnknown. That tolerance is deliberate: one bad tag must not take down the other few thousand on the same device.

What that costs, and what closes it (Gitea #541). A skip is not an error, and it happens before any read. So a driver ignoring part or all of its configuration reads, on /hosts:

Column What it shows Why
Status Healthy nothing faulted — a config miss is not a fault
Errors/5 min 0 the counter counts transitions into Faulted
Last error — never set
Last read advancing live the tags that did map are polling fine

On the wonder Makino in #540 that reading held for weeks while focas-z-34184 ignored 20 of its 22 tags.

Each driver therefore implements ISkippedTagReporter, accumulating into the shared SkippedTagLedger during its tag-table build. The report — authored count, exact skipped count, and a capped sample of RawPath + reason — rides DriverHealthChanged to the Tags column on /hosts, alongside the per-host connectivity of #521 and by the same transport (the Phase 5 telemetry stream, not a table — a driver-only node has no ConfigDb to write to).

Reading the Tags column:

Cell Meaning
— no answer. Either the driver cannot report skips, or it never got as far as building a tag table (it faulted during initialise). Deliberately not the same as reporting none.
12 tags the driver built a table, and skipped nothing.
20 / 22 skipped (amber) partly working — some tags poll, some do not.
22 / 22 skipped (red) polling nothing. The state most likely to look healthy, because it has no failing reads to report.

That first row is a distinction the live rig forced. A pre-fix build rendered 0 tags for two drivers that had faulted during initialise — each with two authored tags — because a never-built ledger returned a zero report. A zero report is a positive claim ("this driver skipped nothing"), and making it on behalf of a driver that never looked is the same quiet inaccuracy the column exists to remove. GetSkippedTags() now returns null until the first committed build.

Three properties worth knowing:

  • A skip never changes driver state. Latching non-Healthy for a config problem would satisfy both halves of the #527 comms-loss conjunction — 90 s without a successful read AND non-Healthy — and a driver whose tags were all skipped has no successful reads by construction, so it would reconnect-loop forever against a device that is perfectly fine. The signal is additive to the state, not a change of it.
  • The count is exact; only the sample is capped (SkippedTagLedger.MaxSample). The tooltip says explicitly when it is showing fewer than it counted — a truncated list that looked complete would have an operator fix twenty tags and believe the job done.
  • The report is replaced per build, never accumulated, and an init that throws part-way leaves the previous report standing rather than publishing the partial one it reached. A truncated snapshot would read as "we now skip fewer tags", the opposite of the truth.

Sql is the one driver that drops a tag at two points, and its report is the union: a TagConfig that does not map (BuildTagTable) and an identifier the §8.1 catalog gate rejects against the live catalog (ApplyCatalogGateAsync). The second is what schema drift produces — a renamed column, a dropped view — months after a deployment sealed green, and reporting only the first would have the driver claim 0 skipped while polling half its tags. In both cases the tag keeps its node and publishes BadNodeIdUnknown; it is only absent from the polled table.

SkippedTagReporterFleetTests pins the invariant reflectively over every IDriver in src/Drivers/, so a thirteenth driver cannot re-introduce the silence.

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 / RTU-over-TCP Driver.Modbus (+ .Addressing, .Contracts) A NModbus-derived in-house client; Transport selects MBAP (TCP) or RTU CRC-16 framing over a socket 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. Transport=RtuOverTcp (ModbusTransportMode, default Tcp) targets serial→Ethernet gateways via ModbusRtuOverTcpTransport + ModbusRtuFraming + ModbusCrc, with ModbusTransportDesyncException carrying distinct CrcMismatch/UnitMismatch reasons; direct-serial transport is descoped. Merged 0f38f486 (#495)
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, IHostConnectivityProbe Gateway/aggregation driver — does not proxy upstream history (no driver has a history capability). 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. Two modes: Plain binds a tag to a concrete topic + JSON path; SparkplugB decodes vendored Eclipse-Tahu protobuf under one spBv1.0/{groupId}/# subscription and binds by the group/edgeNode/device?/metric tuple (birth/alias/seq-gap state machine, death→STALE, scoped rebirth NCMD). IRediscoverable fires on a DBIRTH but is inert platform-side — see #507
MTConnect Driver.MTConnect (+ .Contracts) — Hand-rolled System.Xml.Linq HTTP/XML client against a vendor-neutral MTConnect Agent (/probe, /current, /sample) — no TrakHound dependency (dropped; see the driver doc) IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable Read-only by design (no IWritable). Production data plane is entirely ISubscribable/OnDataChange — IReadable is CLI/test-only. Browse is free via the Wave-0 universal discovery browser, no bespoke browser project. IRediscoverable/IHostConnectivityProbe have no server-side consumer yet — a pre-existing fleet-wide gap, not MTConnect-specific
Sql Driver.Sql (+ .Browser, .Contracts) A Microsoft.Data.SqlClient behind an ISqlDialect seam (SqlServerDialect shipped; Postgres/ODBC deferred) IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe Read-only DB-poll driver with a bespoke schema browser. Credentials are never persisted — the deploy gate rejects a stored connectionString (#498) and catalog identifiers are validated against the live catalog's own spelling (#496). SupportsOnlineDiscovery => false by design (the bespoke browser wins). SqlTagModel.Query is deferred to P3 and rejected end-to-end by parser, validator, planner and editor. Merged 4ad54037
Calculation Driver.Calculation A none — computes from other tags' RawPaths IDriver, IDependencyConsumer, ISubscribable, IReadable Pseudo-driver, not a wire protocol: signal-level tags computed from other tags via a script, with deploy-time scriptId-existence + Tarjan cycle gates. Its probe is parse-only (no backend to reach). runTimeoutMs is authorable through CalculationDriverForm (the "no typed config form" gap — G-1 — was closed 2026-07-27)
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 default historian backend (the other is Historian.SqlServer, below). 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.)
Historian.SqlServer Driver.Historian.SqlServer — Microsoft.Data.SqlClient (a plain SQL Server database, schema hist by default) IHistorianDataSource + IHistorianValueWriter + IAlarmHistorianWriter + IHistorianProvisioning + IHistorianDataUpdater Not a tag driver — a pluggable historian backend selected with ServerHistorian:Backend=SqlServer. Stores typed values with their real status, serves Raw / AtTime / Events / 13 push-down aggregates, and is the only backend that supports OPC UA HistoryUpdate. No IDriver/ITagDiscovery surface.

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, LocalPlatform scope 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, 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, Sparkplug B (birth/alias/seq/rebirth state machine, birth-driven browse picker, death→STALE), and the Known gaps table
  • MTConnect has a short getting-started doc because the hand-rolled-vs-TrakHound divergence, the data-plane precedence rules, and a non-trivial known-limitations list need explaining up front:

    • MTConnect.md — Agent config, FullName==DataItem id, UNAVAILABLE→BadNoCommunication, CONDITION-as-String, browse-via-universal-browser, fixture recipe, deferred write-back
  • Historian.Gateway and Historian.SqlServer (server-side historian backends, not tag drivers) are documented in the main guide:

    • ../Historian.md — backend selection (ServerHistorian:Backend), the SQL Server backend and HistoryUpdate, plus the HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (SendEvent), continuous historization (WriteLiveValues), EnsureTags provisioning, config keys, deployment prerequisites. (The retired Wonderware sidecar backend it replaced: Historian.Wonderware.md.)
  • 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 and are the current reference; that file is the v2-era per-field spec (see its status banner for which drivers it covers).

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-snap7 server; DB/MB read + write round-trip verified end-to-end on :1102
  • AB Legacy — Dockerized ab_server PCCC mode across SLC500 / MicroLogix / PLC-5 profiles (task #224); N/F/L-file round-trip verified end-to-end. /1,0 cip-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 FakeTwinCATClient with native-notification harness
  • FOCAS — no integration fixture, unit-only via FakeFocasClient; FOCAS runs in-process (Tier A) — the Tier C out-of-process isolation once scoped was never built
  • OPC UA Client — Dockerized opc-plc integration 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 mocked Session
  • MQTT — Dockerized eclipse-mosquitto with TLS + real auth on both listeners (no anonymous fallback) + a JSON-publisher sidecar; env-gated live suite (15 tests) — 7 plain (TLS/auth connect, CA pinning both ways, subscribe→value, retained seeding, wrong-password rejection) + 8 Sparkplug against a project-owned C# edge-node simulator on the sparkplug compose profile (birth→alias bind, DDATA, NDEATH→STALE, rebirth NCMD round-trip). Births are never retained, so docker restart otopcua-sparkplug-sim is how you force a fresh one
  • Galaxy — richest harness: gateway E2E + ZB SQL live-smoke + MXAccess opt-in (v1 archive)
  • MTConnect — Dockerized mtconnect/agent + stdlib SHDR adapter (two services, the Agent alone reports everything UNAVAILABLE); 12/12 against the real Agent, canned XML unit fixtures cover the bulk (491/491)
  • HistoricalDataAccess.md — v1 archive of the retired per-driver history design (aggregate mapping, continuation points). History is now served only by the server-side IHistorianDataSource (the selected historian backend: HistorianGateway or SQL Server); no driver implements a history capability, and nodes without isHistorized return BadHistoryOperationUnsupported.
  • AlarmTracking.md — IAlarmSource event 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 — historical design record of the A / B / C tier model. Only DriverTier + the CapabilityPolicy defaults per tier × capability (DriverResilienceOptions.GetTierDefaults) are live; its MemoryTracking / recycle / out-of-process isolation machinery was deleted (#522) and every driver runs Tier A.
  • docs/v2/plan.md — authoritative vision, architecture decisions, migration strategy.