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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user