fix(driver-focas): resolve Low code-review findings (Driver.FOCAS-007,008,009,010,011)

- 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>
This commit is contained in:
Joseph Doherty
2026-05-23 07:45:38 -04:00
parent f7e3e9885e
commit 6575c6e5f6
8 changed files with 522 additions and 64 deletions

View File

@@ -1,3 +1,5 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
@@ -22,8 +24,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly string _driverInstanceId;
private readonly IFocasClientFactory _clientFactory;
private readonly PollGroupEngine _poll;
private readonly ILogger<FocasDriver> _logger;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
// Per-tag-name cache of the FocasAddress parsed once at InitializeAsync. ReadAsync /
// WriteAsync look up the pre-parsed value instead of re-parsing tag.Address on every hot
// call — resolves Driver.FOCAS-008.
private readonly Dictionary<string, FocasAddress> _parsedAddressesByTagName =
new(StringComparer.OrdinalIgnoreCase);
private FocasAlarmProjection? _alarmProjection;
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
// Volatile.Read/Write ensures every thread sees the latest reference without a lock — the
@@ -35,12 +43,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
IFocasClientFactory? clientFactory = null)
IFocasClientFactory? clientFactory = null,
ILogger<FocasDriver>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
_logger = logger ?? NullLogger<FocasDriver>.Instance;
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -82,6 +92,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
$"FOCAS tag '{tag.Name}' ({tag.Address}) rejected by capability matrix: {reason}");
}
_tagsByName[tag.Name] = tag;
// Cache the parsed FocasAddress so ReadAsync / WriteAsync don't re-parse on every
// hot-path call (Driver.FOCAS-008). The address string has already been validated
// by FocasAddress.TryParse above; reusing the parsed record avoids per-tick allocs
// on subscription pollers.
_parsedAddressesByTagName[tag.Name] = parsed;
}
if (_options.Probe.Enabled)
@@ -105,7 +120,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
if (_options.AlarmProjection.Enabled)
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval);
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval, _logger);
if (_options.FixedTree.Enabled)
{
@@ -143,19 +158,26 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
// Cancel-then-dispose can race in tight shutdown loops; swallowing is intentional
// but we now log the cause so a noisy shutdown leaves a Debug trace
// (Driver.FOCAS-007).
try { state.ProbeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
try { state.RecycleCts?.Cancel(); } catch { }
try { state.RecycleCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); }
state.RecycleCts?.Dispose();
state.RecycleCts = null;
try { state.FixedTreeCts?.Cancel(); } catch { }
try { state.FixedTreeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); }
state.FixedTreeCts?.Dispose();
state.FixedTreeCts = null;
state.DisposeClient();
}
_devices.Clear();
_tagsByName.Clear();
_parsedAddressesByTagName.Clear();
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
}
@@ -206,8 +228,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = FocasAddress.TryParse(def.Address)
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
// Parsed at InitializeAsync — defensive fallback re-parse only if the tag was
// somehow not seeded (shouldn't happen, but keeps the call total).
if (!_parsedAddressesByTagName.TryGetValue(def.Name, out var parsed))
{
parsed = FocasAddress.TryParse(def.Address)
?? throw new InvalidOperationException(
$"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
}
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, status, now, now);
@@ -260,8 +288,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = FocasAddress.TryParse(def.Address)
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
if (!_parsedAddressesByTagName.TryGetValue(def.Name, out var parsed))
{
parsed = FocasAddress.TryParse(def.Address)
?? throw new InvalidOperationException(
$"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
}
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(status);
}
@@ -489,10 +521,35 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try
{
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
success = await client.ProbeAsync(ct).ConfigureAwait(false);
// Apply Probe.Timeout so a hung CNC socket gets cancelled at the configured
// budget rather than blocking until the OS TCP timeout (Driver.FOCAS-009).
// TimeSpan.Zero / negative means "no per-probe timeout" — fall back to the loop
// cancellation token unmodified.
var probeTimeout = _options.Probe.Timeout;
if (probeTimeout > TimeSpan.Zero)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(probeTimeout);
success = await client.ProbeAsync(linked.Token).ConfigureAwait(false);
}
else
{
success = await client.ProbeAsync(ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* connect-failure path already disposed + cleared the client */ }
catch (OperationCanceledException ex)
{
// Per-probe timeout fired — the loop is still alive. Treat as a failed probe so
// the host state transitions to Stopped, and log so silent timeouts are visible.
_logger.LogDebug(ex, "FOCAS probe timed out for {Host} after {Timeout}",
state.Options.HostAddress, _options.Probe.Timeout);
}
catch (Exception ex)
{
/* connect-failure path already disposed + cleared the client */
_logger.LogDebug(ex, "FOCAS probe failed for {Host}", state.Options.HostAddress);
}
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
@@ -542,8 +599,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch
catch (Exception ex)
{
_logger.LogDebug(ex, "FOCAS fixed-tree bootstrap failed for {Host} — retrying",
state.Options.HostAddress);
try { await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
@@ -559,7 +618,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
}
catch { /* first-tick poll will retry */ }
catch (Exception ex)
{
/* first-tick poll will retry */
_logger.LogDebug(ex,
"FOCAS bootstrap spindle-loads prime failed for {Host} — first poll tick will retry",
state.Options.HostAddress);
}
}
var programPollDue = DateTime.MinValue;
@@ -591,7 +656,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var loads = await client.GetServoLoadsAsync(ct).ConfigureAwait(false);
PublishServoLoads(state, loads);
}
catch { /* transient — next tick retries */ }
catch (Exception ex)
{
/* transient — next tick retries */
_logger.LogDebug(ex, "FOCAS servo-loads poll failed for {Host}",
state.Options.HostAddress);
}
}
if (cache.Capabilities.SpindleLoad)
{
@@ -600,7 +670,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
}
catch { /* transient */ }
catch (Exception ex)
{
/* transient */
_logger.LogDebug(ex, "FOCAS spindle-loads poll failed for {Host}",
state.Options.HostAddress);
}
}
// Program-info poll runs on its own cadence — much slower than the axis
@@ -615,7 +690,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.LastProgramInfo = program;
if (firstAxisSnap is { } s) state.LastProgramAxisRef = s;
}
catch { /* transient — next tick retries */ }
catch (Exception ex)
{
/* transient — next tick retries */
_logger.LogDebug(ex, "FOCAS program-info poll failed for {Host}",
state.Options.HostAddress);
}
programPollDue = DateTime.UtcNow + programInterval;
}
@@ -631,13 +711,23 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var t = await client.GetTimerAsync(kind, ct).ConfigureAwait(false);
state.LastTimers[kind] = t;
}
catch { /* per-kind failures are non-fatal */ }
catch (Exception ex)
{
/* per-kind failures are non-fatal */
_logger.LogDebug(ex, "FOCAS timer poll failed for {Host} kind={Kind}",
state.Options.HostAddress, kind);
}
}
timerPollDue = DateTime.UtcNow + timerInterval;
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* next tick retries — transient blips are expected */ }
catch (Exception ex)
{
/* next tick retries — transient blips are expected */
_logger.LogDebug(ex, "FOCAS fixed-tree poll tick failed for {Host}",
state.Options.HostAddress);
}
try { await Task.Delay(_options.FixedTree.PollInterval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
@@ -801,7 +891,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// reconnect because the goal is just to release the FWLIB handle slot; a
// readable tick one probe cycle later is an acceptable cost.
try { state.DisposeClient(); }
catch { /* already disposed or race — next EnsureConnected recovers */ }
catch (Exception ex)
{
/* already disposed or race — next EnsureConnected recovers */
_logger.LogDebug(ex, "FOCAS handle-recycle dispose failed for {Host}", state.Options.HostAddress);
}
}
}
@@ -858,7 +952,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
result.Add((state.Options.HostAddress, alarms));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch { /* surface a device-local fault on the next tick */ }
catch (Exception ex)
{
/* surface a device-local fault on the next tick */
_logger.LogDebug(ex, "FOCAS alarm poll failed for {Host}", state.Options.HostAddress);
}
}
return result;
}