A driver builds its RawPath -> definition table at init and tolerates a
per-tag miss: an unmappable TagConfig, an unparseable address, a
capability-matrix rejection. That tolerance is right -- one bad tag must not
take down the other few thousand. What was missing is any statement of the
fact afterwards. A skip is not an error and happens before any read, so the
driver stays Healthy, errors/5 min stays 0, Last error stays empty, and Last
read advances live off the tags that did map. On the wonder Makino in #540
that reading held while focas-z-34184 ignored 20 of its 22 tags.
New optional capability ISkippedTagReporter, backed by a shared
SkippedTagLedger so the reset/commit/cap rules are one implementation rather
than one per driver. The report -- authored count, exact skipped count, and a
capped sample of RawPath + reason -- rides DriverHealthChanged to a new Tags
column on /hosts, over the Phase 5 telemetry stream. No table: a driver-only
node has no ConfigDb to write to, the lesson from #521.
The issue named eight drivers. It is twelve -- every driver in
DriverTypeNames.All has an authored-tag skip path. Calculation (no scriptId),
MTConnect (no readable DataItem id), Galaxy (no attributeRef) and OpcUaClient
(no nodeId) were unlisted, and OpcUaClient's was the quietest of the twelve:
NamespaceMap drops such an entry with a bare `continue` and not even a log
line. SkippedTagReporterFleetTests pins the invariant reflectively over every
IDriver in src/Drivers/, so a thirteenth cannot re-introduce the silence.
Sql drops a tag at TWO points and its report is the union of both: a blob
that does not map, AND an identifier the 8.1 catalog gate rejects against the
live catalog. 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 that driver claim "0 skipped" while polling half its
tags, which is worse than reporting nothing because the column would then be
actively reassuring.
Deliberately additive to driver state, never a change of it. 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 healthy device.
Three details that are load-bearing rather than incidental:
- The skip digest is part of the publish fingerprint. A skipped tag leaves
every other fingerprint component identical to a clean driver's, so without
it PublishHealthSnapshot's dedup swallows exactly the publish carrying the
signal -- the trap that already bit the rediscovery and host-status
channels. The digest covers the sample, not just the counts: a redeploy
that fixes one tag and breaks another leaves both counts unchanged.
- The count is exact; only the sample is capped, and the tooltip says so 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 ledger stages and commits, so an init that throws part-way leaves the
previous report standing. A partial snapshot would read as "we now skip
fewer tags", the opposite of the truth.
The proto field needs no companion presence flag, unlike host_statuses: a
singular message field carries real presence in proto3, and a has_ flag would
collide with protoc's generated HasSkippedTags.
Verified red-first three ways: dropping SkippedTagDigest from the fingerprint
fails the two dedup tests, removing the seam from one driver fails the fleet
guard, and dropping the catalog-gate leg fails the Sql union test. Full
solution builds with zero new warnings against 4e22f024.
22 KiB
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 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 notificationsISkippedTagReporter— 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-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 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/DriverTypeMetadatawere DELETED (Gitea #522, 2026-07-30). They were vestigial — no production code constructed or read them, only their own unit tests did — andAllowedNamespaceKindshad already been orphaned by the retirement of the v3Namespaceentity. 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.Aparameter onDriverFactoryRegistry.cs:46, and no factory insrc/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),IDriverSupervisorhad no implementations, and the trackers were only ever constructed in tests. TheTiercolumn below is therefore uniformly A, and the tier's remaining effect isDriverResilienceOptions.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 |
|---|---|
— |
the driver cannot report skips. Not the same as reporting none. |
12 tags |
the driver reports, 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. |
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,
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, 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==DataItemid,UNAVAILABLE→BadNoCommunication, CONDITION-as-String, browse-via-universal-browser, fixture recipe, deferred write-back
- MTConnect.md — Agent config,
-
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 (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 thesparkplugcompose profile (birth→alias bind, DDATA, NDEATH→STALE, rebirth NCMD round-trip). Births are never retained, sodocker restart otopcua-sparkplug-simis 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 everythingUNAVAILABLE); 12/12 against the real Agent, canned XML unit fixtures cover the bulk (491/491)
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.