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.
37 KiB
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:
- Factory extension (
<X>DriverFactoryExtensions.Register) registers aDriverTypeName → CreateInstance(id, configJson)closure with theDriverFactoryRegistry(wired centrally insrc/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs). Config parses through a string-typed DTO with fail-loudParseEnumhelpers andPropertyNameCaseInsensitive+ comment/trailing-comma tolerance. - 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. - 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-tagTagConfigJSON blobs (parsed once by a per-driver<X>EquipmentTagParserin the Contracts project, cached, cleared on teardown). - 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. - Health is an immutable
DriverHealthsnapshot published viaVolatile.Read/Write; failures setDegradedwhile retainingLastSuccessfulRead;Faultedis reserved for permanent config faults. - Writes return per-item
WriteResultwith protocol-error → OPC UA StatusCode mapping (a per-driver<X>StatusMapperor switch). Accurate per-item statuses are load-bearing: the server's write-outcome self-correction (OtOpcUaNodeManagerreverting the node value on a failed device write) only works if the driver reports the failure. - Probe loop — a background task issuing a cheap protocol-level
handshake, raising
OnHostStatusChangedon Running↔Stopped transitions under a probe lock; plus a stateless<X>DriverProbe : IDriverProbebacking the AdminUI Test Connect button. - Teardown — one shared
TeardownAsyncused by bothShutdownAsyncandDisposeAsync: 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 _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
_gated connection (S7Driver.cs:443-484); S7comm multi-var reads (S7.NetReadMultipleVarsAsync) 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_rdpmcrngsupports 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
ParseEnumlisting valid values (ModbusDriverFactoryExtensions.cs:191-201and 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 withtimeoutMssilently 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
dataTypestring quietly becomes the default type (Int16/DInt/Int/Int32per 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 privateReadEnumhelper is copy-pasted six times instead of living besideEquipmentTagRefResolverin Core.Abstractions. Recommendation: hoist a shared strictReadEnum(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
- S7 reconnect (STAB-1) + a reconnect test template applied to S7 and AbLegacy (UNDER-8) — one field scenario, two drivers, currently fatal.
- TwinCAT native-subscription replay on client recycle (STAB-2).
- AbCip per-runtime operation lock (STAB-4, port from AbLegacy) and FOCAS ConcurrentDictionary caches (STAB-5) — silent-corruption class.
- Modbus transport: timeout/framing-violation ⇒ connection teardown (STAB-3).
- PollGroupEngine v2: absorb S7's backoff + failure-health, wire
onErrorin all five consumers, migrate S7 off its fork (CONV-1, STAB-9, PERF-3, STAB-8). - Shared strict equipment-tag enum parsing + writable/capability parity (CONV-2, UNDER-6) — one Core.Abstractions helper, six deletions.
- Per-protocol read batching (PERF-1/PERF-2), starting with the AbCip planner⇄template-cache join (design already exists in-tree).
- Sweep the one-liners: factory
ILoggerFactory(CONV-3),ResolveHostvia resolver (CONV-4), FOCAS writable-false (UNDER-1), deleteUnsMappingTablegate (UNDER-2), OpcUaClient alarm-filter encoding (UNDER-3).