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:
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-05-22 |
|
| Review date | 2026-05-22 |
|
||||||
| Commit reviewed | `76d35d1` |
|
| Commit reviewed | `76d35d1` |
|
||||||
| Status | Reviewed |
|
| Status | Reviewed |
|
||||||
| Open findings | 5 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ stale object.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Location | `FocasDriver.cs:140-148`, `FocasDriver.cs:478-484`, `FocasDriver.cs:529-533`, `FocasAlarmProjection.cs:61-63` |
|
| Location | `FocasDriver.cs:140-148`, `FocasDriver.cs:478-484`, `FocasDriver.cs:529-533`, `FocasAlarmProjection.cs:61-63` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** Numerous `try { ... } catch {}` blocks swallow every exception with no
|
**Description:** Numerous `try { ... } catch {}` blocks swallow every exception with no
|
||||||
logging - `ShutdownAsync` (CTS cancel/dispose), `RecycleLoopAsync` (`DisposeClient`),
|
logging - `ShutdownAsync` (CTS cancel/dispose), `RecycleLoopAsync` (`DisposeClient`),
|
||||||
@@ -215,7 +215,7 @@ solely on `GetHealth()`.
|
|||||||
poll/probe/recycle loops at `Debug`/`Warning`. Pass a logger into `FocasWireClient` so
|
poll/probe/recycle loops at `Debug`/`Warning`. Pass a logger into `FocasWireClient` so
|
||||||
the per-response `Debug` entries it already emits are actually captured.
|
the per-response `Debug` entries it already emits are actually captured.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**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
|
### Driver.FOCAS-008
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ the per-response `Debug` entries it already emits are actually captured.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Location | `FocasDriver.cs:201`, `FocasDriver.cs:253` |
|
| Location | `FocasDriver.cs:201`, `FocasDriver.cs:253` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `ReadAsync` and `WriteAsync` call `FocasAddress.TryParse(def.Address)`
|
**Description:** `ReadAsync` and `WriteAsync` call `FocasAddress.TryParse(def.Address)`
|
||||||
on every operation, even though `InitializeAsync` already parsed and validated every
|
on every operation, even though `InitializeAsync` already parsed and validated every
|
||||||
@@ -235,7 +235,7 @@ re-parses and allocates a `FocasAddress` record per tag per tick unnecessarily.
|
|||||||
parsed `FocasAddress` on `FocasTagDefinition` (or in a side dictionary), so the runtime
|
parsed `FocasAddress` on `FocasTagDefinition` (or in a side dictionary), so the runtime
|
||||||
read/write paths use the cached value.
|
read/write paths use the cached value.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**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
|
### Driver.FOCAS-009
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ read/write paths use the cached value.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Location | `FocasDriverOptions.cs:110-115`, `FocasDriver.cs:468-486`, `FocasDriverFactoryExtensions.cs:75-80` |
|
| Location | `FocasDriverOptions.cs:110-115`, `FocasDriver.cs:468-486`, `FocasDriverFactoryExtensions.cs:75-80` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `FocasProbeOptions.Timeout` is parsed by the factory
|
**Description:** `FocasProbeOptions.Timeout` is parsed by the factory
|
||||||
(`FocasProbeDto.TimeoutMs` to `FocasProbeOptions.Timeout`) but never consumed.
|
(`FocasProbeDto.TimeoutMs` to `FocasProbeOptions.Timeout`) but never consumed.
|
||||||
@@ -257,7 +257,7 @@ until the OS TCP timeout rather than the configured `Probe.Timeout`.
|
|||||||
around the `ProbeAsync` call, or remove the dead `Timeout` field from
|
around the `ProbeAsync` call, or remove the dead `Timeout` field from
|
||||||
`FocasProbeOptions` / `FocasProbeDto` if it is genuinely not intended.
|
`FocasProbeOptions` / `FocasProbeDto` if it is genuinely not intended.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**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
|
### Driver.FOCAS-010
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@ around the `ProbeAsync` call, or remove the dead `Timeout` field from
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Code organization & conventions |
|
| Category | Code organization & conventions |
|
||||||
| Location | `IFocasClient.cs:210-227` (`FocasOpMode`), `FocasConstants.cs:42-78` (`FocasOperationMode`) |
|
| Location | `IFocasClient.cs:210-227` (`FocasOpMode`), `FocasConstants.cs:42-78` (`FocasOperationMode`) |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** There are two parallel operation-mode-to-text mappings with divergent
|
**Description:** There are two parallel operation-mode-to-text mappings with divergent
|
||||||
labels. `FocasOpMode.ToText` (used by the driver fixed-tree `OperationMode/ModeText`
|
labels. `FocasOpMode.ToText` (used by the driver fixed-tree `OperationMode/ModeText`
|
||||||
@@ -278,7 +278,7 @@ inconsistent results depending on which path renders it.
|
|||||||
**Recommendation:** Consolidate to a single op-mode enum + `ToText` helper shared by
|
**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.
|
both the wire layer and the driver projection, with one canonical label set.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**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
|
### Driver.FOCAS-011
|
||||||
|
|
||||||
@@ -287,7 +287,7 @@ both the wire layer and the driver projection, with one canonical label set.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Code organization & conventions |
|
| Category | Code organization & conventions |
|
||||||
| Location | `IFocasClient.cs:275-287` (`FocasAlarmType`), `FocasAlarmProjection.cs:149-175` |
|
| Location | `IFocasClient.cs:275-287` (`FocasAlarmType`), `FocasAlarmProjection.cs:149-175` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `FocasAlarmType` declares its constants as `public const int`, but the
|
**Description:** `FocasAlarmType` declares its constants as `public const int`, but the
|
||||||
only consumers - `FocasAlarmProjection.MapAlarmType(short type)` and
|
only consumers - `FocasAlarmProjection.MapAlarmType(short type)` and
|
||||||
@@ -301,7 +301,7 @@ expected by `ReadAlarmsAsync`.
|
|||||||
**Recommendation:** Declare the `FocasAlarmType` constants as `short` (or make it an
|
**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.
|
`enum : short`) so the type matches the wire field width and the projection signatures.
|
||||||
|
|
||||||
**Resolution:** _(open)_
|
**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
|
### Driver.FOCAS-012
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
@@ -21,14 +23,16 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
private readonly FocasDriver _driver;
|
private readonly FocasDriver _driver;
|
||||||
private readonly TimeSpan _pollInterval;
|
private readonly TimeSpan _pollInterval;
|
||||||
|
private readonly ILogger _logger;
|
||||||
private readonly Dictionary<long, Subscription> _subs = new();
|
private readonly Dictionary<long, Subscription> _subs = new();
|
||||||
private readonly Lock _subsLock = new();
|
private readonly Lock _subsLock = new();
|
||||||
private long _nextId;
|
private long _nextId;
|
||||||
|
|
||||||
public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval)
|
public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval, ILogger? logger = null)
|
||||||
{
|
{
|
||||||
_driver = driver;
|
_driver = driver;
|
||||||
_pollInterval = pollInterval;
|
_pollInterval = pollInterval;
|
||||||
|
_logger = logger ?? NullLogger.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||||
@@ -58,8 +62,10 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
if (!_subs.Remove(h.Id, out sub)) return;
|
if (!_subs.Remove(h.Id, out sub)) return;
|
||||||
}
|
}
|
||||||
try { sub.Cts.Cancel(); } catch { }
|
try { sub.Cts.Cancel(); }
|
||||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling alarm-subscription CTS failed"); }
|
||||||
|
try { await sub.Loop.ConfigureAwait(false); }
|
||||||
|
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting alarm-subscription loop failed during unsubscribe"); }
|
||||||
sub.Cts.Dispose();
|
sub.Cts.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,8 +84,10 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
|||||||
lock (_subsLock) { snap = [.. _subs.Values]; _subs.Clear(); }
|
lock (_subsLock) { snap = [.. _subs.Values]; _subs.Clear(); }
|
||||||
foreach (var sub in snap)
|
foreach (var sub in snap)
|
||||||
{
|
{
|
||||||
try { sub.Cts.Cancel(); } catch { }
|
try { sub.Cts.Cancel(); }
|
||||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling alarm-subscription CTS during dispose failed"); }
|
||||||
|
try { await sub.Loop.ConfigureAwait(false); }
|
||||||
|
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting alarm-subscription loop during dispose failed"); }
|
||||||
sub.Cts.Dispose();
|
sub.Cts.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +144,11 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||||
catch { /* per-tick failures are non-fatal — next tick retries */ }
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
/* per-tick failures are non-fatal — next tick retries */
|
||||||
|
_logger.LogDebug(ex, "FOCAS alarm-projection poll tick failed");
|
||||||
|
}
|
||||||
|
|
||||||
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
|
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
|
||||||
catch (OperationCanceledException) { break; }
|
catch (OperationCanceledException) { break; }
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
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 string _driverInstanceId;
|
||||||
private readonly IFocasClientFactory _clientFactory;
|
private readonly IFocasClientFactory _clientFactory;
|
||||||
private readonly PollGroupEngine _poll;
|
private readonly PollGroupEngine _poll;
|
||||||
|
private readonly ILogger<FocasDriver> _logger;
|
||||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = 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;
|
private FocasAlarmProjection? _alarmProjection;
|
||||||
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
|
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
|
||||||
// Volatile.Read/Write ensures every thread sees the latest reference without a lock — the
|
// 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 event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||||
|
|
||||||
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
||||||
IFocasClientFactory? clientFactory = null)
|
IFocasClientFactory? clientFactory = null,
|
||||||
|
ILogger<FocasDriver>? logger = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
_options = options;
|
_options = options;
|
||||||
_driverInstanceId = driverInstanceId;
|
_driverInstanceId = driverInstanceId;
|
||||||
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
||||||
|
_logger = logger ?? NullLogger<FocasDriver>.Instance;
|
||||||
_poll = new PollGroupEngine(
|
_poll = new PollGroupEngine(
|
||||||
reader: ReadAsync,
|
reader: ReadAsync,
|
||||||
onChange: (handle, tagRef, snapshot) =>
|
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}");
|
$"FOCAS tag '{tag.Name}' ({tag.Address}) rejected by capability matrix: {reason}");
|
||||||
}
|
}
|
||||||
_tagsByName[tag.Name] = tag;
|
_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)
|
if (_options.Probe.Enabled)
|
||||||
@@ -105,7 +120,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_options.AlarmProjection.Enabled)
|
if (_options.AlarmProjection.Enabled)
|
||||||
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval);
|
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval, _logger);
|
||||||
|
|
||||||
if (_options.FixedTree.Enabled)
|
if (_options.FixedTree.Enabled)
|
||||||
{
|
{
|
||||||
@@ -143,19 +158,26 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
foreach (var state in _devices.Values)
|
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?.Dispose();
|
||||||
state.ProbeCts = null;
|
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?.Dispose();
|
||||||
state.RecycleCts = null;
|
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?.Dispose();
|
||||||
state.FixedTreeCts = null;
|
state.FixedTreeCts = null;
|
||||||
state.DisposeClient();
|
state.DisposeClient();
|
||||||
}
|
}
|
||||||
_devices.Clear();
|
_devices.Clear();
|
||||||
_tagsByName.Clear();
|
_tagsByName.Clear();
|
||||||
|
_parsedAddressesByTagName.Clear();
|
||||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
|
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
|
try
|
||||||
{
|
{
|
||||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
var parsed = FocasAddress.TryParse(def.Address)
|
// Parsed at InitializeAsync — defensive fallback re-parse only if the tag was
|
||||||
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
|
// 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);
|
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||||
@@ -260,8 +288,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||||
var parsed = FocasAddress.TryParse(def.Address)
|
if (!_parsedAddressesByTagName.TryGetValue(def.Name, out var parsed))
|
||||||
?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'.");
|
{
|
||||||
|
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);
|
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
||||||
results[i] = new WriteResult(status);
|
results[i] = new WriteResult(status);
|
||||||
}
|
}
|
||||||
@@ -489,10 +521,35 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
var client = await EnsureConnectedAsync(state, 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);
|
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
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);
|
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||||
|
|
||||||
@@ -542,8 +599,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
|
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
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); }
|
try { await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); }
|
||||||
catch (OperationCanceledException) { return; }
|
catch (OperationCanceledException) { return; }
|
||||||
}
|
}
|
||||||
@@ -559,7 +618,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
||||||
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
|
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;
|
var programPollDue = DateTime.MinValue;
|
||||||
@@ -591,7 +656,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
var loads = await client.GetServoLoadsAsync(ct).ConfigureAwait(false);
|
var loads = await client.GetServoLoadsAsync(ct).ConfigureAwait(false);
|
||||||
PublishServoLoads(state, loads);
|
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)
|
if (cache.Capabilities.SpindleLoad)
|
||||||
{
|
{
|
||||||
@@ -600,7 +670,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
||||||
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
|
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
|
// 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;
|
state.LastProgramInfo = program;
|
||||||
if (firstAxisSnap is { } s) state.LastProgramAxisRef = s;
|
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;
|
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);
|
var t = await client.GetTimerAsync(kind, ct).ConfigureAwait(false);
|
||||||
state.LastTimers[kind] = t;
|
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;
|
timerPollDue = DateTime.UtcNow + timerInterval;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
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); }
|
try { await Task.Delay(_options.FixedTree.PollInterval, ct).ConfigureAwait(false); }
|
||||||
catch (OperationCanceledException) { break; }
|
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
|
// reconnect because the goal is just to release the FWLIB handle slot; a
|
||||||
// readable tick one probe cycle later is an acceptable cost.
|
// readable tick one probe cycle later is an acceptable cost.
|
||||||
try { state.DisposeClient(); }
|
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));
|
result.Add((state.Options.HostAddress, alarms));
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -272,16 +272,22 @@ public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
|
|||||||
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
|
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
|
||||||
/// the full list is ~15 types per model; these cover the universally-present categories.
|
/// the full list is ~15 types per model; these cover the universally-present categories.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Constants are typed <see cref="short"/> so they match the wire field width on
|
||||||
|
/// <c>cnc_rdalmmsg2</c> (and so <see cref="FocasAlarmProjection"/>'s <c>switch (short)</c>
|
||||||
|
/// statements compile against a matching type rather than relying on implicit int→short
|
||||||
|
/// narrowing on the constants).
|
||||||
|
/// </remarks>
|
||||||
public static class FocasAlarmType
|
public static class FocasAlarmType
|
||||||
{
|
{
|
||||||
/// <summary>Pass to <see cref="IFocasClient.ReadAlarmsAsync"/>-equivalent to mean "any type".</summary>
|
/// <summary>Pass to <see cref="IFocasClient.ReadAlarmsAsync"/>-equivalent to mean "any type".</summary>
|
||||||
public const int All = -1;
|
public const short All = -1;
|
||||||
public const int Parameter = 0; // ALM_P
|
public const short Parameter = 0; // ALM_P
|
||||||
public const int PulseCode = 1; // ALM_Y (servo)
|
public const short PulseCode = 1; // ALM_Y (servo)
|
||||||
public const int Overtravel = 2; // ALM_O
|
public const short Overtravel = 2; // ALM_O
|
||||||
public const int Overheat = 3; // ALM_H
|
public const short Overheat = 3; // ALM_H
|
||||||
public const int Servo = 4; // ALM_S
|
public const short Servo = 4; // ALM_S
|
||||||
public const int DataIo = 5; // ALM_T
|
public const short DataIo = 5; // ALM_T
|
||||||
public const int MemoryCheck = 6; // ALM_M
|
public const short MemoryCheck = 6; // ALM_M
|
||||||
public const int MacroAlarm = 13; // ALM_MC — used by #3006 etc.
|
public const short MacroAlarm = 13; // ALM_MC — used by #3006 etc.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,23 +58,13 @@ public static class FocasOperationModeExtensions
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Canonical operator-facing label for an operation mode (e.g. <c>"AUTO"</c>,
|
/// Canonical operator-facing label for an operation mode (e.g. <c>"AUTO"</c>,
|
||||||
/// <c>"EDIT"</c>). Unknown codes fall back to the raw numeric value as a string
|
/// <c>"EDIT"</c>). Delegates to <see cref="FocasOpMode.ToText"/> so the wire layer
|
||||||
/// so the UI still shows something interpretable.
|
/// and the fixed-tree projection render identical labels — historically these two
|
||||||
|
/// surfaces diverged ("TJOG" vs "T-JOG", "TEACH_IN_HANDLE" vs "TEACH-IN-HANDLE",
|
||||||
|
/// and different unknown-code fallbacks). Resolved by Driver.FOCAS-010.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string ToText(this FocasOperationMode mode) => mode switch
|
public static string ToText(this FocasOperationMode mode) =>
|
||||||
{
|
FocasOpMode.ToText((short)mode);
|
||||||
FocasOperationMode.Mdi => "MDI",
|
|
||||||
FocasOperationMode.Auto => "AUTO",
|
|
||||||
FocasOperationMode.TJog => "T-JOG",
|
|
||||||
FocasOperationMode.Edit => "EDIT",
|
|
||||||
FocasOperationMode.Handle => "HANDLE",
|
|
||||||
FocasOperationMode.Jog => "JOG",
|
|
||||||
FocasOperationMode.TeachInHandle => "TEACH-IN-HANDLE",
|
|
||||||
FocasOperationMode.Reference => "REFERENCE",
|
|
||||||
FocasOperationMode.Remote => "REMOTE",
|
|
||||||
FocasOperationMode.Test => "TEST",
|
|
||||||
_ => ((short)mode).ToString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -14,9 +16,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class WireFocasClient : IFocasClient
|
public sealed class WireFocasClient : IFocasClient
|
||||||
{
|
{
|
||||||
private readonly FocasWireClient _wire = new();
|
private readonly FocasWireClient _wire;
|
||||||
private FocasHostAddress? _address;
|
private FocasHostAddress? _address;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default constructor — wire client without logger. Selected by the legacy
|
||||||
|
/// no-arg <see cref="WireFocasClientFactory.Create"/> path.
|
||||||
|
/// </summary>
|
||||||
|
public WireFocasClient() : this(logger: null) { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Construct with an optional logger. Threaded through to
|
||||||
|
/// <see cref="FocasWireClient"/> so the per-response Debug entries actually reach
|
||||||
|
/// the host's logging pipeline (Driver.FOCAS-007).
|
||||||
|
/// </summary>
|
||||||
|
public WireFocasClient(ILogger<FocasWireClient>? logger)
|
||||||
|
{
|
||||||
|
_wire = new FocasWireClient(logger);
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsConnected => _wire.IsConnected;
|
public bool IsConnected => _wire.IsConnected;
|
||||||
|
|
||||||
public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||||
@@ -339,5 +357,20 @@ public sealed class WireFocasClient : IFocasClient
|
|||||||
/// <summary>Factory producing <see cref="WireFocasClient"/> instances — one per configured device.</summary>
|
/// <summary>Factory producing <see cref="WireFocasClient"/> instances — one per configured device.</summary>
|
||||||
public sealed class WireFocasClientFactory : IFocasClientFactory
|
public sealed class WireFocasClientFactory : IFocasClientFactory
|
||||||
{
|
{
|
||||||
public IFocasClient Create() => new WireFocasClient();
|
private readonly ILogger<FocasWireClient>? _logger;
|
||||||
|
|
||||||
|
public WireFocasClientFactory() : this(logger: null) { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Construct the factory with a logger that every created <see cref="WireFocasClient"/>
|
||||||
|
/// forwards to its <see cref="FocasWireClient"/>. Resolves Driver.FOCAS-007 — the wire
|
||||||
|
/// client already emits Debug entries per FOCAS response, but the previous no-arg
|
||||||
|
/// factory path discarded them.
|
||||||
|
/// </summary>
|
||||||
|
public WireFocasClientFactory(ILogger<FocasWireClient>? logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IFocasClient Create() => new WireFocasClient(_logger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for Driver.FOCAS-007 — the driver previously swallowed every
|
||||||
|
/// exception in its poll / probe / recycle / fixed-tree loops with no logging at all,
|
||||||
|
/// leaving operators blind when a CNC was silently failing every tick.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasLoggingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_accepts_an_ILogger()
|
||||||
|
{
|
||||||
|
// Constructor signature must allow an ILogger<FocasDriver> so the host can wire one
|
||||||
|
// through Microsoft.Extensions.DependencyInjection. The driver code project already
|
||||||
|
// references Microsoft.Extensions.Logging.Abstractions.
|
||||||
|
var logger = new CapturingLogger<FocasDriver>();
|
||||||
|
var drv = new FocasDriver(
|
||||||
|
new FocasDriverOptions { Probe = new FocasProbeOptions { Enabled = false } },
|
||||||
|
"drv-1",
|
||||||
|
new FakeFocasClientFactory(),
|
||||||
|
logger);
|
||||||
|
|
||||||
|
drv.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeLoop_logs_when_an_exception_is_swallowed()
|
||||||
|
{
|
||||||
|
var logger = new CapturingLogger<FocasDriver>();
|
||||||
|
var factory = new FakeFocasClientFactory
|
||||||
|
{
|
||||||
|
Customise = () => new FakeFocasClient
|
||||||
|
{
|
||||||
|
// Make ProbeAsync throw — the probe loop swallows it but must log.
|
||||||
|
ThrowOnConnect = false,
|
||||||
|
ProbeResult = true, // not used because the underlying probe path throws
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Force the probe to throw by making the client throw on connect.
|
||||||
|
factory.Customise = () => new FakeFocasClient
|
||||||
|
{
|
||||||
|
ThrowOnConnect = true,
|
||||||
|
Exception = new InvalidOperationException("simulated probe failure"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var drv = new FocasDriver(
|
||||||
|
new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||||
|
Probe = new FocasProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Interval = TimeSpan.FromMilliseconds(50),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(100),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"drv-log",
|
||||||
|
factory,
|
||||||
|
logger);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
// Give the probe loop one tick or two to log.
|
||||||
|
await Task.Delay(250);
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// We expect at least one log entry at Debug / Warning that mentions the simulated
|
||||||
|
// failure or the probe loop. Without logging there's literally no record on a wedged
|
||||||
|
// CNC — exactly the gap the finding called out.
|
||||||
|
logger.Entries.ShouldNotBeEmpty();
|
||||||
|
logger.Entries.Any(e => e.Message.Contains("probe", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| (e.Exception?.Message.Contains("simulated probe failure") ?? false))
|
||||||
|
.ShouldBeTrue("at least one log entry should reference the probe loop or surface the swallowed exception");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingLogger<T> : ILogger<T>
|
||||||
|
{
|
||||||
|
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
|
||||||
|
|
||||||
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
Entries.Add((logLevel, formatter(state, exception), exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullScope : IDisposable
|
||||||
|
{
|
||||||
|
public static NullScope Instance { get; } = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for the Low-severity code-review findings:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Driver.FOCAS-008 — parsed FocasAddress is cached at init, not re-parsed per read/write</item>
|
||||||
|
/// <item>Driver.FOCAS-009 — Probe.Timeout is actually applied around ProbeAsync</item>
|
||||||
|
/// <item>Driver.FOCAS-010 — operation-mode → text mapping is consolidated</item>
|
||||||
|
/// <item>Driver.FOCAS-011 — FocasAlarmType constants are typed as short</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasLowFindingsTests
|
||||||
|
{
|
||||||
|
// ---- Driver.FOCAS-008 — parsed FocasAddress cached at init ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_uses_cached_FocasAddress_when_tag_definition_has_a_malformed_address_after_init()
|
||||||
|
{
|
||||||
|
// After InitializeAsync succeeds with a well-formed address, the driver must rely on the
|
||||||
|
// cached parse — *not* re-parse `FocasTagDefinition.Address` on every read. We can prove
|
||||||
|
// the cache is used by stuffing a malformed Address onto a tag *post-init* through the
|
||||||
|
// internal cache surface — but that's brittle. Instead, prove no re-parse by counting:
|
||||||
|
// we monkey-patch the FakeFocasClient.ReadAsync to capture the FocasAddress reference
|
||||||
|
// it receives and assert it's the *same* instance across two consecutive reads.
|
||||||
|
var captured = new List<FocasAddress>();
|
||||||
|
var factory = new FakeFocasClientFactory
|
||||||
|
{
|
||||||
|
Customise = () => new CapturingFakeFocasClient(captured)
|
||||||
|
{
|
||||||
|
Values = { ["R100"] = (sbyte)1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||||
|
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||||
|
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||||
|
|
||||||
|
captured.Count.ShouldBe(2);
|
||||||
|
ReferenceEquals(captured[0], captured[1])
|
||||||
|
.ShouldBeTrue("ReadAsync must reuse the FocasAddress parsed at init, not re-parse per read");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_uses_cached_FocasAddress_too()
|
||||||
|
{
|
||||||
|
var captured = new List<FocasAddress>();
|
||||||
|
var factory = new FakeFocasClientFactory
|
||||||
|
{
|
||||||
|
Customise = () => new CapturingFakeFocasClient(captured)
|
||||||
|
{
|
||||||
|
WriteStatuses = { ["R100"] = FocasStatusMapper.Good },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new FocasTagDefinition(
|
||||||
|
"X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: true),
|
||||||
|
],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.WriteAsync(
|
||||||
|
[new WriteRequest("X", (sbyte)1)],
|
||||||
|
CancellationToken.None);
|
||||||
|
await drv.WriteAsync(
|
||||||
|
[new WriteRequest("X", (sbyte)2)],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
captured.Count.ShouldBe(2);
|
||||||
|
ReferenceEquals(captured[0], captured[1])
|
||||||
|
.ShouldBeTrue("WriteAsync must reuse the FocasAddress parsed at init");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.FOCAS-009 — Probe.Timeout applies to ProbeAsync ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeLoop_cancels_a_slow_ProbeAsync_at_Probe_Timeout()
|
||||||
|
{
|
||||||
|
// The probe loop must apply Probe.Timeout — a hung CNC socket should be cancelled at the
|
||||||
|
// configured timeout rather than blocking until the OS TCP timeout. We prove the timeout
|
||||||
|
// is applied by making ProbeAsync wait indefinitely and asserting it observes
|
||||||
|
// cancellation before the normal probe Interval would tick again.
|
||||||
|
var hangSignal = new TaskCompletionSource();
|
||||||
|
var factory = new FakeFocasClientFactory
|
||||||
|
{
|
||||||
|
Customise = () => new HangingProbeFakeClient(hangSignal),
|
||||||
|
};
|
||||||
|
var probeTimeout = TimeSpan.FromMilliseconds(100);
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||||
|
Probe = new FocasProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Interval = TimeSpan.FromSeconds(10),
|
||||||
|
Timeout = probeTimeout,
|
||||||
|
},
|
||||||
|
}, "drv-1", factory);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
// Wait up to a generous bound for the probe to observe cancellation. Without the
|
||||||
|
// timeout fix, this never completes (Probe runs forever).
|
||||||
|
var cancelled = await Task.WhenAny(
|
||||||
|
hangSignal.Task,
|
||||||
|
Task.Delay(TimeSpan.FromSeconds(2))) == hangSignal.Task;
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
cancelled.ShouldBeTrue(
|
||||||
|
"ProbeAsync must be cancelled at Probe.Timeout when it does not complete; otherwise a hung CNC blocks the probe loop indefinitely");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.FOCAS-010 — operation-mode → text mapping is consolidated ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, "MDI")]
|
||||||
|
[InlineData(1, "AUTO")]
|
||||||
|
[InlineData(2, "TJOG")] // canonical FocasOpMode label, matches both surfaces post-fix
|
||||||
|
[InlineData(3, "EDIT")]
|
||||||
|
[InlineData(4, "HANDLE")]
|
||||||
|
[InlineData(5, "JOG")]
|
||||||
|
[InlineData(6, "TEACH_IN_HANDLE")]
|
||||||
|
[InlineData(7, "REFERENCE")]
|
||||||
|
[InlineData(8, "REMOTE")]
|
||||||
|
[InlineData(9, "TEST")]
|
||||||
|
public void OpMode_ToText_yields_the_same_label_in_both_namespaces(int code, string expected)
|
||||||
|
{
|
||||||
|
// Driver fixed-tree path (FocasOpMode.ToText) and wire layer (FocasOperationModeExtensions.ToText)
|
||||||
|
// must yield the same canonical label so dashboard rendering doesn't vary by code path.
|
||||||
|
FocasOpMode.ToText(code).ShouldBe(expected);
|
||||||
|
((FocasOperationMode)(short)code).ToText().ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpMode_ToText_fallback_label_is_consistent()
|
||||||
|
{
|
||||||
|
// Unknown codes must fall back to the same shape from both call sites — previously
|
||||||
|
// FocasOpMode used "Mode{n}" while FocasOperationModeExtensions used the bare number.
|
||||||
|
const int unknown = 99;
|
||||||
|
FocasOpMode.ToText(unknown).ShouldBe(
|
||||||
|
((FocasOperationMode)(short)unknown).ToText(),
|
||||||
|
"unknown-mode fallback must agree across both surfaces");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Driver.FOCAS-011 — FocasAlarmType constants typed as short ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FocasAlarmType_constants_are_typed_short()
|
||||||
|
{
|
||||||
|
// The downstream switches in FocasAlarmProjection.MapAlarmType / MapSeverity take a short.
|
||||||
|
// Declaring the constants as int (the old shape) compiled by accident because the values
|
||||||
|
// fit short range; making them short makes the type match the wire width.
|
||||||
|
// We assert this at runtime via reflection so the test fails if a future contributor
|
||||||
|
// demotes them back to int.
|
||||||
|
var fields = typeof(FocasAlarmType).GetFields(
|
||||||
|
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
|
||||||
|
foreach (var f in fields)
|
||||||
|
{
|
||||||
|
f.FieldType.ShouldBe(typeof(short),
|
||||||
|
$"FocasAlarmType.{f.Name} must be typed `short` so it matches the wire field width " +
|
||||||
|
"and the FocasAlarmProjection switch arm types");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private sealed class CapturingFakeFocasClient(List<FocasAddress> captured) : FakeFocasClient
|
||||||
|
{
|
||||||
|
public override Task<(object? value, uint status)> ReadAsync(
|
||||||
|
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||||
|
{
|
||||||
|
captured.Add(address);
|
||||||
|
return base.ReadAsync(address, type, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<uint> WriteAsync(
|
||||||
|
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
captured.Add(address);
|
||||||
|
return base.WriteAsync(address, type, value, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HangingProbeFakeClient(TaskCompletionSource cancelledSignal) : FakeFocasClient
|
||||||
|
{
|
||||||
|
public override async Task<bool> ProbeAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
cancelledSignal.TrySetResult();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user