- Driver.FOCAS-007: optional ILogger<FocasDriver> + alarm-projection logger; log Debug around every formerly-empty catch (probe / shutdown / fixed-tree / recycle / alarms-read / projection). - Driver.FOCAS-008: cache the parsed FocasAddress per tag at InitializeAsync; Read/WriteAsync look it up instead of re-parsing on every call. - Driver.FOCAS-009: ProbeLoopAsync now wraps client.ProbeAsync in a linked CTS honouring Probe.Timeout so a hung CNC socket can't block past the configured limit. - Driver.FOCAS-010: FocasOperationModeExtensions.ToText delegates to FocasOpMode.ToText — single canonical op-mode label surface. - Driver.FOCAS-011: FocasAlarmType constants are typed short to match the cnc_rdalmmsg2 wire field and the projection switch arms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 KiB
Code Review — Driver.FOCAS
| Field | Value |
|---|---|
| Module | src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS |
| Reviewer | Claude Code |
| Review date | 2026-05-22 |
| Commit reviewed | 76d35d1 |
| Status | Reviewed |
| Open findings | 0 |
Checklist coverage
A comprehensive review completes every category, recording "No issues found" where a category produced nothing rather than leaving it blank.
| # | Category | Result |
|---|---|---|
| 1 | Correctness & logic bugs | Driver.FOCAS-001, Driver.FOCAS-002, Driver.FOCAS-003 |
| 2 | OtOpcUa conventions | Driver.FOCAS-004 |
| 3 | Concurrency & thread safety | Driver.FOCAS-005 |
| 4 | Error handling & resilience | Driver.FOCAS-006, Driver.FOCAS-007 |
| 5 | Security | No issues found |
| 6 | Performance & resource management | Driver.FOCAS-008 |
| 7 | Design-document adherence | Driver.FOCAS-009 |
| 8 | Code organization & conventions | Driver.FOCAS-010, Driver.FOCAS-011 |
| 9 | Testing coverage | Driver.FOCAS-012 |
| 10 | Documentation & comments | No issues found |
Findings
Driver.FOCAS-001
| Field | Value |
|---|---|
| Severity | High |
| Category | Correctness & logic bugs |
| Location | FocasDriverFactoryExtensions.cs:54-86, FocasDriverFactoryExtensions.cs:132-140 |
| Status | Resolved |
Description: FocasDriverConfigDto exposes only Backend, Series, TimeoutMs,
Devices, Tags, and Probe. It has no FixedTree, AlarmProjection, or
HandleRecycle properties, and CreateInstance never sets those three options on
FocasDriverOptions. As a result, a deployment that follows the documented config -
docs/drivers/FOCAS.md shows "FixedTree": { "Enabled": true },
"AlarmProjection": { "Enabled": true }, and "HandleRecycle": { "Enabled": true }
inside Config - is parsed with PropertyNameCaseInsensitive and the unknown sections
are discarded. The features stay at their hard-coded defaults (all Enabled = false).
The fixed-node tree never appears, alarm subscriptions throw NotSupportedException
("FOCAS alarm projection is disabled"), and handle recycling never runs - despite the
operator explicitly opting in.
Recommendation: Add FixedTree, AlarmProjection, and HandleRecycle DTO classes
to FocasDriverConfigDto, parse their TimeSpan/bool fields, and populate the
corresponding FocasDriverOptions properties in CreateInstance. Consider enabling
strict JSON handling (UnmappedMemberHandling.Disallow) so future unknown config
sections fail loudly instead of being dropped.
Resolution: Resolved 2026-05-22 — added FixedTreeDto/AlarmProjectionDto/HandleRecycleDto to FocasDriverConfigDto and Build* mappers in CreateInstance that populate the matching FocasDriverOptions properties (missing section / field keeps its default).
Driver.FOCAS-002
| Field | Value |
|---|---|
| Severity | High |
| Category | Correctness & logic bugs |
| Location | WireFocasClient.cs:164-179, FocasDriver.cs:513, FocasDriver.cs:593 |
| Status | Resolved |
Description: The fixed-tree bootstrap probes the ProgramInfo capability via
SafeTryProbe(() => client.GetProgramInfoAsync(ct)) and treats a non-null result as
"supported". But WireFocasClient.GetProgramInfoAsync never throws on a FOCAS error
return code: ReadExecutingProgramNameAsync, ReadBlockCountAsync, and
ReadOperationModeCodeAsync all return FocasResult<T> envelopes, and the method
substitutes defaults (string.Empty, 0) when IsOk is false instead of throwing. It
only throws from RequireConnected(). Consequently GetProgramInfoAsync always
returns a non-null FocasProgramInfo, so Capabilities.ProgramInfo is set true even
on a CNC series that returns EW_FUNC/EW_NOOPT for cnc_exeprgname2/cnc_rdopmode.
The driver then emits the Program/ and OperationMode/ subtrees and polls them every
tick against a controller that does not support them - the exact "nodes that only ever
return BadDeviceFailure" outcome the capability suppression was designed to prevent
(docs/drivers/FOCAS.md, "Per-series node suppression").
Recommendation: Make GetProgramInfoAsync throw (or return a nullable result) when
the underlying cnc_exeprgname2 / cnc_rdopmode calls report a non-zero RC, so
SafeTryProbe can correctly classify the series. At minimum require the program-name
or op-mode read to be IsOk before declaring the capability present.
Resolution: Resolved 2026-05-22 — WireFocasClient.GetProgramInfoAsync now throws InvalidOperationException when neither the cnc_exeprgname2 nor the cnc_rdopmode read is IsOk, so SafeTryProbe records ProgramInfo as unsupported on series that answer EW_FUNC/EW_NOOPT.
Driver.FOCAS-003
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | FocasDriver.cs:71-79 |
| Status | Resolved |
Description: In InitializeAsync, capability-matrix validation only runs when
_devices.TryGetValue(tag.DeviceHostAddress, out var device) succeeds. A tag whose
DeviceHostAddress does not match any configured device (a common config typo, e.g. a
trailing :8193 mismatch or a wrong host) silently skips validation and is still added
to _tagsByName. The mistake is not surfaced at load time - it only manifests at read
time as BadNodeIdUnknown (ReadAsync lines 191-194), defeating the documented goal
that "config errors now fail at load instead of per-read"
(docs/v2/focas-version-matrix.md).
Recommendation: After parsing the tag address, if _devices does not contain
tag.DeviceHostAddress, throw an InvalidOperationException naming the tag and the
unresolved device host so the operator fixes the typo at startup.
Resolution: Resolved 2026-05-22 — InitializeAsync now throws InvalidOperationException naming the tag and the unresolved device when _devices does not contain tag.DeviceHostAddress, preventing silent skip-and-defer to per-read BadNodeIdUnknown.
Driver.FOCAS-004
| Field | Value |
|---|---|
| Severity | Medium |
| Category | OtOpcUa conventions |
| Location | FocasDriver.cs:374-379, WireFocasClient.cs:48-50 |
| Status | Resolved |
Description: DiscoverAsync emits user tags with
SecurityClass = tag.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
and FocasTagDefinition.Writable defaults to true (also defaulted to true in the
factory - t.Writable ?? true). But the production wire backend's
WireFocasClient.WriteAsync unconditionally returns FocasStatusMapper.BadNotWritable
- the driver is read-only against FOCAS by design (
docs/drivers/FOCAS.md). The result is that every tag is advertised in the address space as a writableOperatenode, yet every write attempt fails. This is misleading to OPC UA clients and to theDriverNodeManagerACL layer, which will grant write permission on nodes that can never be written.
Recommendation: Either default Writable to false for the FOCAS driver, or have
DiscoverAsync force SecurityClassification.ViewOnly when the active backend cannot
write. Given the wire backend is read-only and is the only production backend, treating
all FOCAS tags as ViewOnly is the simplest correct behaviour.
Resolution: Resolved 2026-05-22 — DiscoverAsync now unconditionally emits SecurityClassification.ViewOnly for all user-authored tags; the Writable config field no longer influences the advertised security class since the wire backend never writes.
Driver.FOCAS-005
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Concurrency & thread safety |
| Location | FocasDriver.cs:28, FocasDriver.cs:206-215, FocasDriver.cs:261, FocasDriver.cs:274 |
| Status | Resolved |
Description: _health is a plain (non-volatile) field mutated from multiple
concurrent contexts - ReadAsync, WriteAsync, and the per-device ProbeLoopAsync can
all run on different threads simultaneously (subscriptions go through PollGroupEngine
timers; probe loops are Task.Run). Several updates are read-modify-write - e.g.
new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ...) reads _health
then writes a new instance - so a concurrent update can be lost or a stale
LastSuccessfulRead propagated. While DriverHealth is an immutable record and the
reference write is atomic, the lack of synchronization means GetHealth() can observe
torn-in-time state and successful-read timestamps can regress.
Recommendation: Guard _health reads/writes with a lock, or use
Interlocked.Exchange/Volatile around the whole record reference and compute the new
value from a single captured snapshot. The DeviceState/HostState transition already
uses ProbeLock; apply the same discipline to driver health.
Resolution: Resolved 2026-05-22 — All _health reads use Volatile.Read(ref _health) and all writes use Volatile.Write(ref _health, ...), ensuring every thread observes the latest reference and multi-step read-modify-write sequences capture a stable snapshot before computing the new value.
Driver.FOCAS-006
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Error handling & resilience |
| Location | FocasDriver.cs:859-874, WireFocasClient.cs:22-31 |
| Status | Resolved |
Description: EnsureConnectedAsync reuses the cached IFocasClient instance across
a transient disconnect: it only checks device.Client is { IsConnected: true } and
otherwise calls ConnectAsync again on the same object. For a WireFocasClient whose
underlying FocasWireClient has been disposed (e.g. via a HandleRecycle /
DisposeClient race, or a prior teardown), every subsequent call hits
FocasWireClient.ThrowIfDisposed and throws ObjectDisposedException. In ReadAsync
that exception is caught only by the generic catch (Exception ex) and mapped to a
permanent BadCommunicationError - the device stays wedged with no recovery path until
ReinitializeAsync is invoked, because the reconnect logic never discards the disposed
client.
Recommendation: On any connect/use failure, treat a disposed or non-connected client
as unrecoverable and recreate it from _clientFactory. Simplest: in
EnsureConnectedAsync, when device.Client is non-null but not connected, dispose and
null it before creating a fresh instance, rather than retrying ConnectAsync on the
stale object.
Resolution: Resolved 2026-05-22 — EnsureConnectedAsync now unconditionally disposes and nulls any existing non-connected client before calling _clientFactory.Create(), preventing ObjectDisposedException loops on a stale WireFocasClient after a HandleRecycle race or prior teardown.
Driver.FOCAS-007
| Field | Value |
|---|---|
| Severity | Low |
| Category | Error handling & resilience |
| Location | FocasDriver.cs:140-148, FocasDriver.cs:478-484, FocasDriver.cs:529-533, FocasAlarmProjection.cs:61-63 |
| Status | Resolved |
Description: Numerous try { ... } catch {} blocks swallow every exception with no
logging - ShutdownAsync (CTS cancel/dispose), RecycleLoopAsync (DisposeClient),
FixedTreeLoopAsync transient catches, ProbeLoopAsync, and the alarm projection's
sub.Cts.Cancel(). The driver takes no ILogger dependency at all (only
FocasWireClient optionally accepts one, and the driver never supplies it). A CNC that
is silently failing every probe/poll tick produces no diagnostic trail, which conflicts
with the project's Serilog logging convention and forces field troubleshooting to rely
solely on GetHealth().
Recommendation: Inject an ILogger<FocasDriver> and log caught exceptions in the
poll/probe/recycle loops at Debug/Warning. Pass a logger into FocasWireClient so
the per-response Debug entries it already emits are actually captured.
Resolution: Resolved 2026-05-23 — FocasDriver now takes an optional ILogger<FocasDriver> (defaulting to NullLogger) and every previously-empty catch { } in ShutdownAsync / ProbeLoopAsync / FixedTreeLoopAsync / RecycleLoopAsync / ReadActiveAlarmsAcrossDevicesAsync now logs at Debug with the host address + context. FocasAlarmProjection also accepts an optional ILogger (forwarded by the driver) so its unsubscribe / dispose / per-tick poll swallows log. WireFocasClientFactory gained a logger-accepting overload that threads through to FocasWireClient, so its per-response Debug entries actually reach the host pipeline.
Driver.FOCAS-008
| Field | Value |
|---|---|
| Severity | Low |
| Category | Performance & resource management |
| Location | FocasDriver.cs:201, FocasDriver.cs:253 |
| Status | Resolved |
Description: ReadAsync and WriteAsync call FocasAddress.TryParse(def.Address)
on every operation, even though InitializeAsync already parsed and validated every
tag address. On a subscription hot path (each poll tick re-enters ReadAsync) this
re-parses and allocates a FocasAddress record per tag per tick unnecessarily.
Recommendation: Parse each tag address once at InitializeAsync and store the
parsed FocasAddress on FocasTagDefinition (or in a side dictionary), so the runtime
read/write paths use the cached value.
Resolution: Resolved 2026-05-23 — FocasDriver now holds a _parsedAddressesByTagName side dictionary populated at InitializeAsync. ReadAsync and WriteAsync look up the cached FocasAddress instance; the defensive fallback TryParse only fires if a tag was somehow not seeded. The cache is cleared on ShutdownAsync. Regression test ReadAsync_uses_cached_FocasAddress_when_tag_definition_has_a_malformed_address_after_init (and the matching WriteAsync variant) asserts the same FocasAddress instance is reused across calls.
Driver.FOCAS-009
| Field | Value |
|---|---|
| Severity | Low |
| Category | Design-document adherence |
| Location | FocasDriverOptions.cs:110-115, FocasDriver.cs:468-486, FocasDriverFactoryExtensions.cs:75-80 |
| Status | Resolved |
Description: FocasProbeOptions.Timeout is parsed by the factory
(FocasProbeDto.TimeoutMs to FocasProbeOptions.Timeout) but never consumed.
ProbeLoopAsync calls client.ProbeAsync(ct) with only the probe-loop cancellation
token; no per-probe timeout is applied, and EnsureConnectedAsync uses
_options.Timeout rather than Probe.Timeout. A hung CNC socket during a probe blocks
until the OS TCP timeout rather than the configured Probe.Timeout.
Recommendation: Apply Probe.Timeout as a linked CancellationTokenSource timeout
around the ProbeAsync call, or remove the dead Timeout field from
FocasProbeOptions / FocasProbeDto if it is genuinely not intended.
Resolution: Resolved 2026-05-23 — FocasDriver.ProbeLoopAsync now wraps client.ProbeAsync in a linked CancellationTokenSource that fires after Probe.Timeout (skipped when the timeout is <= TimeSpan.Zero). On timeout the loop logs the cancellation at Debug and surfaces it as a failed probe, so a hung CNC socket transitions the host to Stopped at the configured budget instead of blocking on the OS TCP timeout. Regression test ProbeLoop_cancels_a_slow_ProbeAsync_at_Probe_Timeout asserts the cancellation reaches the fake ProbeAsync within the configured 100 ms.
Driver.FOCAS-010
| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | IFocasClient.cs:210-227 (FocasOpMode), FocasConstants.cs:42-78 (FocasOperationMode) |
| Status | Resolved |
Description: There are two parallel operation-mode-to-text mappings with divergent
labels. FocasOpMode.ToText (used by the driver fixed-tree OperationMode/ModeText
node) yields "TJOG", "TEACH_IN_HANDLE"; FocasOperationModeExtensions.ToText (in
the Wire layer) yields "T-JOG", "TEACH-IN-HANDLE". They also use different fallback
formats (Mode{mode} vs the bare number). The same concept is encoded twice with
inconsistent results depending on which path renders it.
Recommendation: Consolidate to a single op-mode enum + ToText helper shared by
both the wire layer and the driver projection, with one canonical label set.
Resolution: Resolved 2026-05-23 — FocasOperationModeExtensions.ToText now delegates to FocasOpMode.ToText((short)mode), so the wire layer and the driver fixed-tree projection render identical labels. FocasOpMode keeps its existing labels (TJOG, TEACH_IN_HANDLE, Mode{n} fallback), which are now the single canonical surface. Regression theory OpMode_ToText_yields_the_same_label_in_both_namespaces cross-checks every defined code; OpMode_ToText_fallback_label_is_consistent covers the unknown-code path.
Driver.FOCAS-011
| Field | Value |
|---|---|
| Severity | Low |
| Category | Code organization & conventions |
| Location | IFocasClient.cs:275-287 (FocasAlarmType), FocasAlarmProjection.cs:149-175 |
| Status | Resolved |
Description: FocasAlarmType declares its constants as public const int, but the
only consumers - FocasAlarmProjection.MapAlarmType(short type) and
MapSeverity(short type) - take a short and switch against these int constants. It
compiles only because the values (0..13) fit in short range as constant expressions.
The type mismatch is a latent maintenance hazard: adding a constant above
short.MaxValue, or changing the projection signatures, would break the switch in
non-obvious ways. FocasAlarmType.All is -1 and is also passed where a short is
expected by ReadAlarmsAsync.
Recommendation: Declare the FocasAlarmType constants as short (or make it an
enum : short) so the type matches the wire field width and the projection signatures.
Resolution: Resolved 2026-05-23 — every FocasAlarmType constant (All, Parameter, PulseCode, Overtravel, Overheat, Servo, DataIo, MemoryCheck, MacroAlarm) is now typed short, matching the wire field width on cnc_rdalmmsg2 and the switch (short type) arms in FocasAlarmProjection.MapAlarmType / MapSeverity. Regression test FocasAlarmType_constants_are_typed_short uses reflection to guarantee the type is preserved against future drift.
Driver.FOCAS-012
| Field | Value |
|---|---|
| Severity | Medium |
| Category | Testing coverage |
| Location | FocasDriverFactoryExtensions.cs, FocasDriver.cs:495-629 (FixedTreeLoopAsync) |
| Status | Resolved |
Description: The unit test project does not exercise
FocasDriverFactoryExtensions.CreateInstance with FixedTree / AlarmProjection /
HandleRecycle config sections - which is why the config-mapping gap in
Driver.FOCAS-001 was not caught. There is also no test that drives the fixed-tree
bootstrap / capability-probe path (FixedTreeLoopAsync), so the false-positive
ProgramInfo capability in Driver.FOCAS-002 is untested, and the
EnsureConnectedAsync reconnect-after-disconnect path (Driver.FOCAS-006) has no
coverage.
Recommendation: Add factory tests that round-trip a full JSON config including the
three opt-in sections and assert the options reach the driver; add a
FakeFocasClient-driven test for fixed-tree bootstrap capability classification
(including the unsupported-program-info case); add a reconnect test that disposes the
fake client mid-session and asserts recovery.
Resolution: Resolved 2026-05-22 — Added FocasDriverMediumFindingsTests.cs covering: unknown-DeviceHostAddress init throw (003), ViewOnly enforcement for all tags (004), Volatile _health under concurrent reads (005), reconnect-after-external-dispose recovery (006), and a factory full-round-trip test for all three opt-in config sections (012).