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

617 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `Dictionary`s 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 `_probeLock` —
`OpcUaClientDriver.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 `_gate`d 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. |
---
## Recommended remediation order
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).