Files
lmxopcua/archreview/05-protocol-drivers.md
T
Joseph Doherty 9fadead6a6 docs(archreview): remediation plans + fix flagged doc drift
Add the 7 per-domain design+implementation plans (archreview/plans/) with
an index, produced from the 2026-07-08 architecture review.

Fix two confirmed doc drifts the review flagged (theme #5):
- CLAUDE.md KNOWN LIMITATION 2: the continuous-historization historized-ref
  feed IS wired (AddressSpaceApplier.FeedHistorizedRefs -> UpdateHistorizedRefs
  -> recorder); rewrite to reflect that value-capture is code-complete and only
  the live end-to-end + restart-convergence verification remains.
- CLAUDE.md ScriptAnalysis gating: endpoints use Roles=Administrator,Designer
  via RequireAuthorization, not the FleetAdmin policy.
2026-07-08 16:14:37 -04:00

37 KiB
Raw Blame History

Architecture Review 05 — Protocol Drivers

Date: 2026-07-08 Commit: 9cad9ed0 Scope: the seven cross-platform protocol drivers and their satellites — Driver.Modbus (+ .Addressing, .Contracts), Driver.S7 (+ .Contracts), Driver.AbCip (+ .Contracts), Driver.AbLegacy (+ .Contracts), Driver.TwinCAT (+ .Contracts), Driver.FOCAS (+ .Contracts), Driver.OpcUaClient (+ .Contracts, .Browser), and src/Drivers/Cli/* (six per-driver CLI harnesses + Cli.Common). Test projects under tests/Drivers/ and tests/Drivers/Cli/ were assessed for coverage only. Galaxy and the Historian drivers are out of scope.

Method: Modbus was read exhaustively as the reference driver (it is the documented origin of the shared PollGroupEngine and the richest read planner); each other driver's main client/poller/factory/probe/contracts files were then read in full and diffed structurally against the Modbus shape. Cross-cutting greps covered TODO/HACK/NotImplemented, JSON serializer options, PollGroupEngine construction, and interface declarations. File references are repo-relative; drivers are abbreviated to their suffix (e.g. Driver.S7/S7Driver.cs).


Architecture Overview

The common driver shape

Every protocol driver is expected to follow the same template:

  1. Factory extension (<X>DriverFactoryExtensions.Register) registers a DriverTypeName → CreateInstance(id, configJson) closure with the DriverFactoryRegistry (wired centrally in src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs). Config parses through a string-typed DTO with fail-loud ParseEnum helpers and PropertyNameCaseInsensitive + comment/trailing-comma tolerance.
  2. Driver class implements IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable (Modbus, Driver.Modbus/ModbusDriver.cs:21-22), with optional capability interfaces (IAlarmSource, IRediscoverable, IHistoryProvider) per protocol.
  3. Reference resolution goes through the shared EquipmentTagRefResolver<TDef> (src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs), bridging authored tag-table names and raw equipment-tag TagConfig JSON blobs (parsed once by a per-driver <X>EquipmentTagParser in the Contracts project, cached, cleared on teardown).
  4. Subscriptions overlay a polling loop via the shared PollGroupEngine (Core.Abstractions/PollGroupEngine.cs) for protocols without a push model; the driver supplies the batch reader + on-change bridge.
  5. Health is an immutable DriverHealth snapshot published via Volatile.Read/Write; failures set Degraded while retaining LastSuccessfulRead; Faulted is reserved for permanent config faults.
  6. Writes return per-item WriteResult with protocol-error → OPC UA StatusCode mapping (a per-driver <X>StatusMapper or switch). Accurate per-item statuses are load-bearing: the server's write-outcome self-correction (OtOpcUaNodeManager reverting the node value on a failed device write) only works if the driver reports the failure.
  7. Probe loop — a background task issuing a cheap protocol-level handshake, raising OnHostStatusChanged on Running↔Stopped transitions under a probe lock; plus a stateless <X>DriverProbe : IDriverProbe backing the AdminUI Test Connect button.
  8. Teardown — one shared TeardownAsync used by both ShutdownAsync and DisposeAsync: cancel loops, await them, dispose CTSs, clear caches, dispose the transport.

Modbus adds the most advanced read planner in the fleet: block-read coalescing (MaxReadGap), auto-prohibition of failing ranges with bisection re-probing (ModbusDriver.cs:687-795, 553-677), chunking over per-device caps, per-register bit-RMW locks, deadband publish filtering, and WriteOnChangeOnly suppression with read-side invalidation.

Per-driver conformance matrix

Aspect Modbus (ref) S7 AbCip AbLegacy TwinCAT FOCAS OpcUaClient
LOC (driver proj) 2,374 1,979 3,762 1,830 2,261 5,007 2,347
IPerCallHostResolver yes no yes yes yes yes no (single endpoint, defensible)
Extra capabilities IAlarmSource IRediscoverable IAlarmSource IAlarmSource, IHistoryProvider
Subscription model PollGroupEngine bespoke forked loop PollGroupEngine PollGroupEngine native ADS push (default) + PollGroupEngine fallback PollGroupEngine real OPC UA MonitoredItems
onError sink passed to engine no n/a no no no no n/a
Reconnect story transport auto-reconnect + geometric backoff none evict + recreate handle, no backoff no eviction on data path lazy per-call reconnect, native subs orphaned lazy reconnect per tick, no backoff SessionReconnectHandler + re-arm (best)
Read batching block coalescing + bisection per-tag per-tag; UDT planner half-built per-tag per-tag (no ADS sum-read) per-address, single-flight socket batched ReadValueIdCollection
Deadband / WriteOnChangeOnly yes / yes no / no no / no no / no no / no no / no n/a (passthrough)
Bit-level writes RMW + per-register lock native S7 bit write RMW + per-parent lock (DINT-only assumption) RMW + per-parent lock, width-aware RMW + per-parent lock (4-byte width assumption unverified) n/a (read-only backend) n/a
Health via Volatile/volatile yes plain field plain field volatile plain field yes plain field
InitializeAsync honours configJson no yes yes no yes (empty-config quirk) yes yes
Factory Register takes ILoggerFactory yes no no yes no no yes
Options Validate() no (none in fleet) no no no no no no
Live browse / discovery declared tags only declared tags only declared + opt-in controller browse + UDT fan-out declared only (correct for PCCC) declared + opt-in symbol browse + expander declared + capability-gated fixed tree full recursive remote browse
CLI harness yes yes yes yes yes (+browse) yes none

Findings

Severity scale: Critical = live data-loss/unavailability in a realistic field scenario; High = correctness or stability defect likely to bite; Medium = architectural debt / divergence with concrete failure modes; Low = hygiene.

1. Stability

STAB-1 — Critical — S7 has no reconnect path at all. Plc.OpenAsync is called exactly once, in InitializeAsync (Driver.S7/S7Driver.cs:165-167). Read/write failures set Degraded and keep using the dead Plc (S7Driver.cs:479-483, 995-1001); the probe loop detects the outage, transitions Stopped (S7Driver.cs:1351-1383) — and keeps probing the same dead connection forever. A transient PLC reboot or network blip permanently kills the driver instance until an external redeploy/restart. Every sibling driver has some recovery path (Modbus: transport-level reconnect+backoff; TwinCAT/FOCAS: lazy recreate; AbCip: handle eviction). Recommendation: on socket-level PlcException, dispose and lazily reopen the Plc under _gate (mirror TwinCAT's EnsureConnectedAsync), or let the probe loop own recycle-on-Stopped. Note the test suite has zero reconnect tests for S7 (see TEST-1), which is how this shipped.

STAB-2 — Critical — TwinCAT native subscriptions are silently orphaned after reconnect. Native ADS notifications are the default mode (Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs:31). When EnsureConnectedAsync discards a stale client (Driver.TwinCAT/TwinCATDriver.cs:615-619), AdsTwinCATClient.Dispose clears its notification table (AdsTwinCATClient.cs:422-436) and nothing re-runs AddNotificationAsync on the replacement client — all subscribed data flow stops with no Bad status and no error. Compounding: the fast path keys on IsConnected (TwinCATDriver.cs:606), which reflects the local AMS port, not wire liveness, so the replacement path may never even trigger; and a probe failure never recycles the client (TwinCATDriver.cs:542-544). Recommendation: record registration intent (symbol, interval, handler) per subscription and replay it whenever the per-device client instance changes; make probe-detected outage force a client recycle.

STAB-3 — High — Modbus transport desynchronizes after a response timeout. SendAsync reconnect-retries only on IsSocketLevelFailure (Driver.Modbus/ModbusTcpTransport.cs:155-165, 257-261). A per-op timeout surfaces as OperationCanceledException — not socket-level — so the socket is kept with a half-read (or still-in-flight) response. The next transaction then reads the stale response, hits the TxId mismatch check (ModbusTcpTransport.cs:233-235), and throws InvalidDataException — which is also not socket-level, so the stream is still not torn down. The single-flight connection stays permanently desynchronized until a real socket error or the (optional, default-off) idle-disconnect fires. Recommendation: treat per-op timeout and any framing violation (TxId mismatch, truncated header) as connection-fatal: tear down the socket before propagating.

STAB-4 — High — AbCip runs concurrent operations on shared libplctag handles with no serialization. ReadSingleAsync / ReadGroupAsync / WriteAsync execute Read→GetStatus→Decode and Encode→Write→GetStatus on cached IAbCipTagRuntime instances with zero locking (Driver.AbCip/AbCipDriver.cs:544-575, 614-637, 708-726) while the server read path, every poll-group loop, and the alarm-projection loop (AbCipAlarmProjection.cs:228) can hit the same handle concurrently. AbLegacy documents this exact hazard and guards it with a per-runtime operation lock (“A libplctag Tag handle is not safe for concurrent Read/GetStatus/DecodeValue”, Driver.AbLegacy/AbLegacyDriver.cs:775-787); the fix never propagated to AbCip. Consequences: torn/wrong values decoded with Good status, cross-attributed GetStatus results. Recommendation: port AbLegacy's GetRuntimeLock pattern to AbCip.

STAB-5 — High — FOCAS fixed-tree caches are plain Dictionarys mutated across threads. LastFixedSnapshots / LastServoLoads / LastSpindleLoads / LastTimers (Driver.FOCAS/FocasDriver.cs:1184-1194) are written by FixedTreeLoopAsync (FocasDriver.cs:752, 793, 836-839) while TryReadFixedTree reads them on ReadAsync callers' threads (FocasDriver.cs:912, 920, 945). Concurrent write-during-read on Dictionary<K,V> is undefined behaviour (corruption or throw on resize). The neighbouring _parsedAddressesByTagName already got the ConcurrentDictionary treatment (FocasDriver.cs:40); these did not. Recommendation: convert to ConcurrentDictionary or swap immutable snapshots.

STAB-6 — High — AbLegacy probe-loop shutdown race; S7 unsubscribe race. AbLegacy discards the probe task at spawn (_ = Task.Run(...), Driver.AbLegacy/AbLegacyDriver.cs:118) and ShutdownAsync cancels then immediately disposes the CTS and runtimes without awaiting loop exit (AbLegacyDriver.cs:156-170) — a winding-down loop can raise OnHostStatusChanged after shutdown and touch a disposed CTS. AbCip fixed this identical bug (retained ProbeTask + phased shutdown, Driver.AbCip/AbCipDriver.cs:295-298, 332-365); the fix never propagated back. S7 has the mirror-image bug in UnsubscribeAsync: cancel + dispose the CTS without draining PollTask (Driver.S7/S7Driver.cs:1185-1193), so Task.Delay on the disposed source can throw ObjectDisposedException that the loop only catches as OCE (S7Driver.cs:1233-1234) — PollGroupEngine.StopState exists precisely to prevent this (PollGroupEngine.cs:99-112, 136). Recommendation: await the loop task (bounded) before disposing the CTS in both places.

STAB-7 — High — InitializeAsync ignores driverConfigJson in Modbus and AbLegacy. AbLegacy's _options is readonly ctor state and Initialize/Reinitialize never parse the parameter (Driver.AbLegacy/AbLegacyDriver.cs:16, 78-153); Modbus likewise uses only ctor options (Driver.Modbus/ModbusDriver.cs:167-197). S7, AbCip and TwinCAT deliberately re-parse (Driver.AbCip/AbCipDriver.cs:229-236 documents “rather than being silently discarded”). Any config change delivered through ReinitializeAsync on a live instance is silently discarded for these two drivers. TwinCAT has a related quirk: a parsed config with zero devices AND zero tags is silently discarded in favour of ctor options (Driver.TwinCAT/TwinCATDriver.cs:89-94), so an intentional “empty the driver” redeploy is ignored. Recommendation: make config re-parse on initialize a template requirement; add a consuming test per driver.

STAB-8 — Medium — No reconnect backoff anywhere except the Modbus transport and S7's poll loop. A dead device pays: AbCip — a full Tag create + Forward Open per tag per poll tick (evict-recreate, AbCipDriver.cs:891-897); FOCAS — a full two-socket reconnect attempt per tick of each of three loops (Driver.FOCAS/FocasDriver.cs:1089-1120); TwinCAT — a connect attempt per call (TwinCATDriver.cs:602-651). PollGroupEngine itself polls at a fixed cadence regardless of failures — S7's bespoke loop is the only subscriber-side backoff in the fleet (S7Driver.cs:1284-1293, capped 30 s). Recommendation: add optional failure backoff to PollGroupEngine (adopt S7's ComputeBackoffDelay) and a small connect-attempt throttle to the lazy-reconnect drivers.

STAB-9 — Medium — PollGroupEngine's onError sink is dead code: no driver passes it. All five consumers construct the engine without the error callback (ModbusDriver.cs:115, AbCipDriver.cs:133, AbLegacyDriver.cs:65, TwinCATDriver.cs:69, FocasDriver.cs:72), so a reader exception in a poll tick (e.g. RequireTransport after teardown, reader contract violation) is silently swallowed — the precise “silently-broken subscription” failure mode the engine's own doc comment warns about (PollGroupEngine.cs:21-25, 164-169). Recommendation: pass onError routing to the driver's health surface + logger in all five; consider making the parameter required.

STAB-10 — Medium — AbLegacy never evicts failed tag handles on the data path. Non-zero status and transport exceptions leave the cached runtime in place (AbLegacyDriver.cs:246-259, 269-274) with no recreate-on-failure (contrast AbCipDriver.cs:891-897 and AbLegacy's own probe loop at AbLegacyDriver.cs:456-461). A handle invalidated by a PLC state change pins the tag Bad until full driver reinit.

STAB-11 — Medium — RMW locks don't cover direct parent writes (AbCip, AbLegacy, TwinCAT). The per-parent semaphore serializes bit-RMW callers against each other, but a direct write to the parent word/DINT itself goes through the normal write path without taking the same lock (AbCipDriver.cs:708-710 vs 787-818; same shape in AbLegacy and TwinCAT), so it can interleave with an in-flight read-modify-write → lost update. Additionally AbCip's RMW hard-codes DINT while AbCipTagPath accepts .N on any parent (Driver.AbCip/AbCipTagPath.cs:17-19), and TwinCAT's RMW always accesses the parent as 4-byte UDInt — explicitly flagged as an unverified assumption that will likely fail DeviceInvalidSize on WORD/BYTE parents on real hardware (Driver.TwinCAT/AdsTwinCATClient.cs:96-107).

STAB-12 — Medium — Blocking/uncancellable teardown paths. FOCAS's sync Dispose() performs a close-PDU exchange (SendPdu + ReadPdu) on a NetworkStream with no ReadTimeout and no token (Driver.FOCAS/Wire/FocasWireClient.cs:164-175) — a stalled CNC can wedge ShutdownAsync indefinitely. TwinCAT's ConnectAsync calls the blocking SDK Connect and ignores both its token and timeout parameter for the connect itself (AdsTwinCATClient.cs:73-80).

STAB-13 — Positive. Write-outcome surfacing — the seam the server's value-revert self-correction depends on — is honoured by every protocol driver: per-item WriteResult with typed exception→status mapping (Modbus ModbusDriver.cs:897-942; S7 S7Driver.cs:921-1006 including the PUT/GET-denied → Faulted escalation; AbCip AbCipDriver.cs:732-775; AbLegacy AbLegacyDriver.cs:348-366; TwinCAT TwinCATDriver.cs:332-349 with a numerically-verified ADS error table; OpcUaClient passes upstream codes verbatim and distinguishes post-dispatch cancellation as BadTimeout "outcome unknown" — OpcUaClientDriver.cs:753-779, the most careful non-idempotency handling in the fleet). Nothing is fire-and-forget except the AbCip alarm acknowledge path (UNDER-5). OpcUaClient's reconnect state machine (double-arm guard, re-arm on exhaustion, session swap under _probeLockOpcUaClientDriver.cs:1908-2058) is the strongest reconnect implementation in the subsystem.

2. Performance

PERF-1 — High — Five of seven drivers read one tag per wire round-trip. Only Modbus (block coalescing + auto-prohibition/bisection, ModbusDriver.cs:687-795) and OpcUaClient (single ReadValueIdCollection per batch, OpcUaClientDriver.cs:640-666) batch. The others are O(N) round trips per poll tick on serialized links:

  • S7: one PDU per tag under one _gated connection (S7Driver.cs:443-484); S7comm multi-var reads (S7.Net ReadMultipleVarsAsync) unused.
  • TwinCAT: one ADS call per symbol; ADS sum-read — Beckhoff's documented batching mechanism — unused (TwinCATDriver.cs:203-248).
  • FOCAS: one request/response per address on a strictly single-flight socket (SynchronizedFocasClient.cs:34); pmc_rdpmcrng supports ranges but each read fetches exactly one value's width (Wire/WireFocasClient.cs:296-334). Throughput ceiling ≈ tags × RTT.
  • AbLegacy: one PCCC transaction per scalar; no span coalescing of adjacent file elements (N7:0..N7:2), the natural Modbus analog. Recommendation: per-protocol batching is the single biggest scalability lever; priority order = TwinCAT sum-read, S7 multi-var, FOCAS PMC range reads, AbLegacy spans.

PERF-2 — High — AbCip's whole-UDT batched read exists in two halves that were never joined. The read planner (Driver.AbCip/AbCipUdtReadPlanner.cs:32-100) can collapse N members into one parent read, but only via declaration-order layout — off by default because it produces “silently-plausible wrong numbers” when member order diverges (AbCipDriverOptions.cs:60-72). Meanwhile the driver already fetches and caches true member offsets via the CIP Template Object (AbCipDriver.cs:151-182, AbCipTemplateCache.cs) — used only for discovery, never consulted by the planner (AbCipUdtMemberLayout.cs:22-24 even points at the richer path). Net: in every safe configuration, N UDT members = N CIP round trips per tick. Recommendation: feed AbCipUdtShape offsets into the planner; retire the unsafe declaration-order mode.

PERF-3 — High — S7's forked poll loop diffs arrays by reference. PollOnceAsync compares Equals(lastSeen?.Value, current.Value) (S7Driver.cs:1304); array reads return a fresh CLR array every poll, so every subscribed array tag fires OnDataChange every tick, flooding the dependency mux/DPS downstream. PollGroupEngine fixed exactly this with StructuralComparisons (PollGroupEngine.cs:203-209); the bespoke S7 copy never picked it up. (Also a Conventions finding — see CONV-1.)

PERF-4 — Medium — OpcUaClient serializes all traffic on one global SemaphoreSlim(1,1). Reads, writes, discovery, subscription creates, acks and HistoryReads all queue on _gate (OpcUaClientDriver.cs:78, 626, 713, 836, 1186, 1377, 1451, 1653, 1825). OPC UA sessions multiplex service calls natively; a multi-second HistoryRead or full re-discovery blocks every live read/write behind it. The gate is only needed for session-swap consistency, which _probeLock + re-read-inside-critical-section already provide.

PERF-5 — Medium — OpcUaClient discovery enrichment issues one giant un-chunked Read and silently degrades on rejection. EnrichAndRegisterVariablesAsync sends pending.Count * 4 ReadValueIds in a single call (OpcUaClientDriver.cs:1029-1046) — 40k operations for a 10k-node server. A server enforcing MaxNodesPerRead returns BadTooManyOperations; the blanket catch (:1049-1057) then registers every variable as Int32/ViewOnly/non-historized with no log or health signal. Recommendation: chunk client-side; log the fallback.

PERF-6 — Low — Assorted per-tick allocation churn. TwinCAT re-parses TwinCATSymbolPath on every read/write/subscribe call — lists + StringBuilder per tag per tick (TwinCATDriver.cs:220, 284, 468); S7 caches parses (_parsedByName) and is the model. Modbus publishes a fresh DriverHealth record per successful tag read (ModbusDriver.cs:285). AbCip's BuildArray<T> boxes per element (LibplctagTagRuntime.cs:107-118). None are hot enough to be urgent.

PERF-7 — Low — Deadband exists only in Modbus. The publish-suppression deadband (ModbusDriver.cs:136-159) and WriteOnChangeOnly never generalized; noisy analog CNC/PLC signals on the other five poll-based drivers publish every jitter. The mechanism is driver-agnostic and belongs in or beside PollGroupEngine.

3. Conventions

CONV-1 — High — S7 forked the poll loop instead of using PollGroupEngine, and the fork has diverged in both directions. PollGroupEngine's own doc claims it serves “Modbus, AB CIP, S7, FOCAS” (PollGroupEngine.cs:8-9) but S7 re-ships the entire loop (S7Driver.cs:1165-1307). The fork is better in two ways the engine never absorbed (capped exponential failure backoff :1284-1293; poll failures logged + degrade health :1258-1274) and worse in two ways the engine already fixed (reference-equality array diff — PERF-3; CTS disposed without draining the loop — STAB-6). This is the textbook cost of template divergence: each side owns fixes the other needs. Recommendation: extend PollGroupEngine with backoff + onError-driven health, then move S7 onto it and delete the fork.

CONV-2 — High — Three different strictness tiers parse the same config, and the most-used one is the most permissive. For every driver the same enum field is parsed three ways:

  • Factory: string-typed DTO + fail-loud ParseEnum listing valid values (ModbusDriverFactoryExtensions.cs:191-201 and equivalents) — deliberate, good.
  • Probe: deserializes with JsonStringEnumConverter; Modbus's probe binds the runtime options type (ModbusDriverOptions, TimeSpan fields) rather than the factory DTO shape (ModbusDriverProbe.cs:19-24, 36), so a DB config with timeoutMs silently loses its timeout in the probe and tag-shape mismatches can make the probe reject/misread a config the factory accepts. OpcUaClient documents probe/factory parse-parity as the rule (OpcUaClientDriverProbe.cs:22); Modbus violates it.
  • Equipment-tag parser: silent fallback — an unknown or typo'd dataType string quietly becomes the default type (Int16/DInt/ Int/Int32 per driver) instead of a rejection (Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs:65-67, S7EquipmentTagParser.cs:59-61, AbCipEquipmentTagParser.cs:86-88, AbLegacyEquipmentTagParser.cs:60-62, TwinCATEquipmentTagParser.cs:47-49, FocasEquipmentTagParser.cs:43-45). A mis-typed equipment tag reads and writes the wrong width with Good status. The identical private ReadEnum helper is copy-pasted six times instead of living beside EquipmentTagRefResolver in Core.Abstractions. Recommendation: hoist a shared strict ReadEnum (present-but-invalid ⇒ parse failure ⇒ BadNodeIdUnknown); make probes parse the factory DTO.

CONV-3 — Medium — Factory registration asymmetry leaves four drivers logging to NullLogger in production. S7, TwinCAT, AbCip and FOCAS Register(registry) take no ILoggerFactory (S7DriverFactoryExtensions.cs:19, TwinCATDriverFactoryExtensions.cs:18, AbCipDriverFactoryExtensions.cs:21, FocasDriverFactoryExtensions.cs:36) even though all four driver classes accept a logger; the bootstrap consequently can't pass one (Host/Drivers/DriverFactoryBootstrap.cs:100-107). All the warning paths those drivers carefully log (poll failures, prohibitions, live-risk assumptions) are invisible in production. Modbus/AbLegacy/OpcUaClient/ Galaxy have the logger-aware overload. Recommendation: one-line fix per factory; align all eight Register signatures.

CONV-4 — Medium — ResolveHost mis-keys equipment-tag references in every multi-device driver. The implementations consult only _tagsByName (authored names); an equipment-tag TagConfig-JSON reference never matches and falls back to the driver-level/first-device host (AbCipDriver.cs:483-488, AbLegacyDriver.cs:496-501, TwinCATDriver.cs:585-592, FocasDriver.cs:1082-1087; Modbus ModbusDriver.cs:125-131 has the same shape but per-tag UnitId isn't in the equipment parser anyway). Per-host Polly bulkhead/circuit-breaker keys are therefore wrong for equipment tags on multi-device instances — a broken device B can trip device A's breaker and vice versa. Since equipment tags are the primary authoring model post-Galaxy-standardization, the per-call host resolver is effectively inert for the flagship path. Recommendation: route ResolveHost through EquipmentTagRefResolver and derive the host from the parsed def (deviceHostAddress / unitId).

CONV-5 — Medium — Health-field publication conventions drift. The template documents Volatile.Read/Write (ModbusDriver.cs:217-228); FOCAS conforms (FocasDriver.cs:43-46), AbLegacy uses a volatile field with rationale (AbLegacyDriver.cs:29-34), while S7 (S7Driver.cs:124, 1272), AbCip (AbCipDriver.cs:95), TwinCAT and OpcUaClient (OpcUaClientDriver.cs:96) use plain fields. Immutable record swap means no torn reads — this is a visibility/staleness nit — but four different idioms for the same one-line concern is exactly the divergence this review is hunting. Related one-offs: TwinCAT declares Healthy at init with zero wire contact (TwinCATDriver.cs:115) where every other driver proves the connection first; TwinCAT reads host state without its probe lock (TwinCATDriver.cs:524-525); AbLegacy never refreshes health on successful writes (AbLegacyDriver.cs:344-346 vs AbCipDriver.cs:725); AbLegacy silently clobbers duplicate tag names (AbLegacyDriver.cs:91) where AbCip fails fast (AbCipDriver.cs:249-256).

CONV-6 — Medium — Contracts layering is uniform except OpcUaClient, which drags the whole OPC UA SDK into its Contracts. Six Contracts projects are POCO options + equipment-tag parser (FOCAS's csproj even enforces “NO PackageReference. NO ProjectReference.”), but Driver.OpcUaClient.Contracts carries a full OPC UA stack reference because NamespaceMap lives there (OpcUaClient.Contracts.csproj:9) — every consumer wanting just the options DTO (probe hosts, AdminUI editors, Browser) transitively loads the SDK. This is the previously-deferred OpcUaClient.Contracts-002 finding; it remains the right call to move NamespaceMap out. Minor: all Contracts projects use the parent namespace without a .Contracts suffix — consistent, just surprising.

CONV-7 — Low/Medium — CLI harness divergences. The six CLIs share DriverCommandBase + SnapshotFormatter and a uniform probe/read/write/subscribe verb set, but: ParseValue/ParseBool is copy-pasted six times (*/Commands/WriteCommand.cs), option validation is a different shape in every base and absent entirely in AbLegacy (AbLegacyCommandBase.cs--timeout-ms 0 reaches the driver), the abstract Timeout init-setter is a silent no-op in five bases but throws NotSupportedException in AbCip (AbCipCommandBase.cs:43), and only TwinCAT exposes a browse command despite AbCip also supporting controller browse (hardcoded EnableControllerBrowse=false, AbCipCommandBase.cs:63). OpcUaClient has no CLI at all (the server-side Client.CLI tests the server, not this driver against a third-party endpoint). Recommendation: hoist value parsing + a standard Validate() into Cli.Common; add an OpcUaClient CLI or document the gap.

CONV-8 — Low — Dead/no-op config knobs and stale scaffold docs. DisableFC23 is a documented no-op (ModbusDriverOptions.cs:83-89); AbCip ConnectionSize is plumbed but never applied (AbCipDriverOptions.cs:97-103); AbLegacy's class doc still says read/write “ship in PRs 2 and 3” (AbLegacyDriver.cs:9-11); OpcUaClient's header still says “PR 66 ships the scaffold: IDriver only” (OpcUaClientDriver.cs:12-14). No options class in the fleet has a Validate() method — validation is scattered across factory throws and init guards (S7's init guards are the best of breed, S7Driver.cs:314-385).

4. Underdeveloped areas

UNDER-1 — High — FOCAS advertises writes it cannot perform. The only real backend returns BadNotWritable for every address (Wire/WireFocasClient.cs:71-73), yet FocasTagDefinition.Writable defaults true with a doc-comment citing write tests (FOCAS.Contracts/FocasDriverOptions.cs:152-158) and FocasEquipmentTagParser hard-codes Writable: true (FocasEquipmentTagParser.cs:35) — so equipment tags can surface as Operate-writable OPC UA nodes whose writes always fail. The driver-side write status mapping (FocasDriver.cs:353-381) is currently dead code. Recommendation: either implement PMC writes in the wire client or force Writable:false at the parser/discovery seams until it exists.

UNDER-2 — High — OpcUaClient's UnsMappingTable is validated as mandatory but consumed nowhere. ValidateNamespaceKind hard-fails Equipment-kind configs lacking a mapping table (OpcUaClientDriver.cs:330-355), yet no code reads the table — DiscoverAsync builds the local tree purely from remote browse structure (:824-1105). Operators are forced to author config with zero runtime effect; the promised remote→UNS remapping feature does not exist. Recommendation: delete the gate or build the feature.

UNDER-3 — High — OpcUaClient alarm source-node filtering compares mismatched NodeId encodings. OnEventNotification filters session-relative ns=N;… renderings against caller-supplied refs by ordinal string compare (OpcUaClientDriver.cs:1337, 1538-1539), while the driver's own persisted references are stable nsu=… strings (:808-809). Any non-empty filter built from stored refs silently drops every event; only empty-filter subscriptions work today.

UNDER-4 — Medium — S7 feature gaps make authored array tags unreachable. S7TagDto has no ArrayCount field and BuildTag never sets it (S7DriverFactoryExtensions.cs:80-90, 137-151) — driver-config array tags are silently scalar; arrays only work as equipment tags (S7EquipmentTagParser.cs:73-87). Array writes are unsupported (S7Driver.cs:1014-1017), wide-type arrays are a stated follow-up (:861-862), and UInt32 surfaces lossily as Int32 (S7Driver.cs:1149-1152).

UNDER-5 — Medium — AbCip alarm acknowledge outcomes are discarded. AcknowledgeAsync fire-and-forgets the write results (AbCipAlarmProjection.cs:143-149); a failed ack (undeclared .Acked member, non-writable, comms error) is invisible to the operator, health, and the Part 9 caller — conditions stick un-acked with no visible cause. Related discovery weak spots: with EnableControllerBrowse on, one unreachable PLC faults the entire multi-device DiscoverAsync (uncaught await foreach, AbCipDriver.cs:970-1037), and browsed tags default to writable/Operate (CipSymbolObjectDecoder.cs:91).

UNDER-6 — Medium — Equipment-tag parsers lag the authored-tag feature set. Modbus's parser ignores unitId, deadband, stringByteOrder, writable, coalesceProhibited and the address-grammar string, and forces Writable: true (ModbusEquipmentTagParser.cs:54-57); AbLegacy and FOCAS also hard-code writable-true (AbLegacyEquipmentTagParser.cs:52, FocasEquipmentTagParser.cs:35) — read-only equipment tags are unauthorable on three drivers (AbCip honours a writable key, AbCipEquipmentTagParser.cs:53-54). FOCAS equipment tags additionally bypass the FocasCapabilityMatrix pre-flight gate that authored tags get (FocasDriver.cs:243-247 vs :115-119). As equipment tags are the primary authoring model, the parsers deserve parity plus a shared conformance test.

UNDER-7 — Medium — Live-risk assumptions documented but ungated. TwinCAT carries two explicit "unverified against real hardware" blocks on core paths — array read shape (AdsTwinCATClient.cs:109-116) and Flat-mode SubSymbols population (:263-277, struct members could silently vanish from discovery) — plus the bit-RMW width assumption (STAB-11). AbLegacy's array decode rests on five explicitly unproven layout assumptions with no PCCC fixture (LibplctagLegacyTagRuntime.cs:64-88). FOCAS's cnc_getfigure command id and servo-load scaling are sim-validated only (FocasWireClient.cs:391-395, 943-947). These are honest, well-documented debts — but nothing aggregates them into a "needs bench time" checklist.

UNDER-8 — Medium — Test coverage is codec-heavy, failure-path-light, and uneven. Approximate unit-test counts: Modbus 191 (+71 addressing), AbCip 234, FOCAS 143, S7 130, TwinCAT 119, AbLegacy 114, OpcUaClient 93 (+10 browser). Integration suites are all skip-gated on fixture reachability; Modbus's is the richest (30 tests incl. exception injection and device-quirk profiles), AbLegacy's the thinnest (2), and TwinCAT's 13 require a real TC3 XAR runtime — no docker fixture exists, so it never runs automated. Systemic gaps: zero reconnect tests for S7 and AbLegacy (the STAB-1/STAB-10 findings live exactly there), zero concurrency tests for S7 and AbCip (STAB-4 lives there; only AbLegacy has a dedicated runtime-concurrency suite), and OpcUaClient has the widest feature surface on the lowest test density (though its failure-path spread — reconnect, failover, stale-session race, continuation points — is the best). Cli.Common's DriverCommandBase has no direct tests. There is zero TODO/HACK/FIXME/NotImplementedException debt in the subsystem — all NotSupportedException sites are deliberate fail-fast gates with handlers.

UNDER-9 — Low — OpcUaClient is a 2,154-line monolith. Six separable responsibilities share one file (config/PKI/identity, failover sweep, recursive discovery, live subscriptions, alarms/acks, the full IHistoryProvider, plus the reconnect state machine). The reconnect state machine is the trickiest concurrency in the subsystem and deserves extraction for isolated testability. Also: subscription transfer rests on the unset SDK default TransferSubscriptionsOnReconnect (OpcUaClientDriver.cs:1946-1949), and BuildCertificateIdentity only loads PKCS#12 while the options doc promises PEM support (:479-484 vs OpcUaClientDriverOptions.cs:68-75).


Maturity ratings

Dimension Rating (1-5) Justification
Stability 3 The template (Modbus transport + OpcUaClient reconnect machine) is genuinely robust and write-outcome surfacing is universal, but two Critical connection-loss failures (S7 no-reconnect, TwinCAT orphaned native subs), an AbCip handle-concurrency hazard, and fleet-wide missing backoff mean a routine PLC power-cycle defeats half the fleet.
Performance 2 Only 2 of 7 drivers batch reads; the rest are O(N) serialized round trips per poll tick, AbCip's flagship UDT batching is half-built, and deadband exists only in Modbus — the subsystem scales by tag count, not by request count.
Conventions 3 The shared seams (PollGroupEngine, EquipmentTagRefResolver, factory/probe pattern, StatusMapper) are real and mostly followed, but the S7 poll-loop fork, three-tier config-parse strictness, six-way ReadEnum copy-paste, logger-registration asymmetry, and drifting health idioms show fixes not flowing back to the template.
Underdeveloped areas 3 Zero stub/TODO debt and honest live-risk documentation, offset by advertised-but-dead features (FOCAS writes, UnsMappingTable, DisableFC23, ConnectionSize), equipment-tag parsers lagging the primary authoring model, and reconnect/concurrency test gaps precisely where the Critical bugs live.

  1. S7 reconnect (STAB-1) + a reconnect test template applied to S7 and AbLegacy (UNDER-8) — one field scenario, two drivers, currently fatal.
  2. TwinCAT native-subscription replay on client recycle (STAB-2).
  3. AbCip per-runtime operation lock (STAB-4, port from AbLegacy) and FOCAS ConcurrentDictionary caches (STAB-5) — silent-corruption class.
  4. Modbus transport: timeout/framing-violation ⇒ connection teardown (STAB-3).
  5. PollGroupEngine v2: absorb S7's backoff + failure-health, wire onError in all five consumers, migrate S7 off its fork (CONV-1, STAB-9, PERF-3, STAB-8).
  6. Shared strict equipment-tag enum parsing + writable/capability parity (CONV-2, UNDER-6) — one Core.Abstractions helper, six deletions.
  7. Per-protocol read batching (PERF-1/PERF-2), starting with the AbCip planner⇄template-cache join (design already exists in-tree).
  8. Sweep the one-liners: factory ILoggerFactory (CONV-3), ResolveHost via resolver (CONV-4), FOCAS writable-false (UNDER-1), delete UnsMappingTable gate (UNDER-2), OpcUaClient alarm-filter encoding (UNDER-3).