Files
lmxopcua/docs/drivers
Joseph Doherty 678aa3aa53 fix(sql): read keyValue with the same scalar read as whereValue (Driver.Sql.Contracts-001)
The two value-bearing fields were read asymmetrically and nothing said why.
whereValue went through ReadScalar (string/number/boolean — the design doc's own
wide-row example authors it as the number 7); keyValue went through the string-only
ReadString, so a numerically-authored key refused the tag outright. Nothing downstream
justified the split: both are BOUND as DbParameters and both are matched back as text —
SqlPollReader.IndexByKey stringifies the key cell precisely so an authored key can face
an INTEGER key column.

The refusal was also mis-explained. Inspect branched on ReadString(root, "keyValue") is
null, so a numerically-authored key drew "Sql KeyValue tag has no 'keyValue'" — telling
the operator the field is missing when it is right there in the blob, the #549 shape of
an authoring surface disagreeing with the runtime about WHY.

keyValue now uses ReadScalar; Inspect mirrors it with HasScalar and says what a usable
key is; SqlTagConfigModel loads it with the shared TagConfigJson.GetScalarText (which
also replaces SqlRowSelectorModel's private copy), so the editor is not left stricter
than the parser. An object/array/null key is still refused. docs/drivers/Sql.md updated.

RED first: 4 failing in Driver.Sql.Tests.
2026-08-10 08:07:15 -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
  • IHistoryProvider — driver-side raw / processed / at-time / events HistoryRead (see HistoricalDataAccess.md)
  • 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 HistorianGateway backend), distinct from the driver-side IHistoryProvider HistoryRead 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 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, 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. 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 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, 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, 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, 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 (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), 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; 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-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; Tier C out-of-process isolation scoped but not shipped
  • 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 — IHistoryProvider dispatch, aggregate mapping, continuation points. The OPC UA Client driver is the only driver that implements driver-side IHistoryProvider (it forwards HistoryRead to the upstream server); the AVEVA Historian path is served server-side by the HistorianGateway-backed IHistorianDataSource instead. Other drivers do not implement the interface and 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 — tier system (A / B / C), shared CapabilityPolicy defaults per tier × capability, MemoryTracking hybrid formula, and process-level recycle rules.
  • docs/v2/plan.md — authoritative vision, architecture decisions, migration strategy.