diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs index d2d46458..9c31bffe 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs @@ -28,6 +28,7 @@ public sealed class ProbeCommand : FocasCommandBase { ConfigureLogging(); ValidateOptions(); + ValidateDataType(Address, DataType); var ct = console.RegisterCancellationHandler(); var probeTag = new FocasTagDefinition( diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs index df042344..9218a2c9 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs @@ -19,7 +19,7 @@ public sealed class ReadCommand : FocasCommandBase /// Gets the data type to interpret the address as. [CommandOption("type", 't', Description = - "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")] + "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16). String only on FIXED: paths.")] public FocasDataType DataType { get; init; } = FocasDataType.Int16; /// @@ -28,6 +28,7 @@ public sealed class ReadCommand : FocasCommandBase ConfigureLogging(); // Validate numeric option ranges before any driver work. ValidateOptions(); + ValidateDataType(Address, DataType); var ct = console.RegisterCancellationHandler(); var tagName = SynthesiseTagName(Address, DataType); diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs index 1a3cb5f4..ee126fc0 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs @@ -18,7 +18,7 @@ public sealed class SubscribeCommand : FocasCommandBase /// Gets the data type of the address. [CommandOption("type", 't', Description = - "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")] + "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16). String only on FIXED: paths.")] public FocasDataType DataType { get; init; } = FocasDataType.Int16; /// Gets the polling interval in milliseconds. @@ -33,6 +33,7 @@ public sealed class SubscribeCommand : FocasCommandBase // --interval-ms) before any driver work so a zero/negative interval surfaces as a // clean CommandException rather than a tight-spinning poll loop. ValidateOptions(IntervalMs); + ValidateDataType(Address, DataType); var ct = console.RegisterCancellationHandler(); var tagName = ReadCommand.SynthesiseTagName(Address, DataType); diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs index bd383041..22cfb431 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs @@ -20,7 +20,7 @@ public sealed class WriteCommand : FocasCommandBase /// Gets the data type of the value to write. [CommandOption("type", 't', Description = - "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")] + "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16). String only on FIXED: paths.")] public FocasDataType DataType { get; init; } = FocasDataType.Int16; /// Gets the value to write. @@ -37,6 +37,7 @@ public sealed class WriteCommand : FocasCommandBase // port/timeout surfaces as a clean CommandException rather than an opaque // downstream exception. ValidateOptions(); + ValidateDataType(Address, DataType); var ct = console.RegisterCancellationHandler(); var tagName = ReadCommand.SynthesiseTagName(Address, DataType); diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs index 62965d52..e57ce8bf 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs @@ -89,4 +89,17 @@ public abstract class FocasCommandBase : DriverCommandBase throw new CliFx.Exceptions.CommandException( $"--interval-ms must be positive (got {iv})."); } + + /// + /// Refuses an --address / --type pair the driver cannot serve (String on a PMC / PARAM / MACRO + /// address — the driver skips such a tag, so the command would only ever print BadNodeIdUnknown). Same + /// rule as the driver and the AdminUI editor: . + /// + /// The FOCAS address. + /// The data type. + protected static void ValidateDataType(string address, FocasDataType type) + { + if (FocasTagDefinitionFactory.DescribeDataTypeMismatch(type, address) is { } error) + throw new CliFx.Exceptions.CommandException(error); + } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs index d495dad1..b65150f6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs @@ -53,6 +53,9 @@ public static class FocasTagDefinitionFactory // Strict enum read: a typo'd dataType rejects the tag (→ BadNodeIdUnknown) // instead of silently defaulting to a wrong-width Good. if (!TagConfigJson.TryReadEnumStrict(root, "dataType", FocasDataType.Int32, out var dataType)) return false; + // A data type no read of this address can produce (String on PMC / PARAM / MACRO) rejects the tag: + // served, it published a boxed number at Good into a String node. + if (DescribeDataTypeMismatch(dataType, address) is not null) return false; // FOCAS authored tags carry no `writable` key (writability lives on the Tag entity / node ACL): // absent ⇒ read-only. An explicit writable:true is honoured for the driver's internal write gate // (default-false, so a missing key stays read-only — the note is preserved). @@ -73,6 +76,26 @@ public static class FocasTagDefinitionFactory catch (InvalidOperationException) { return false; } } + /// + /// The ONE address/data-type compatibility rule, shared by the mapper (and so the driver's init and the + /// deploy inspector), the AdminUI tag editor and the CLI. is only + /// servable from the fixed tree (Identity / Program name / mode text …): a PMC, parameter or macro read + /// returns a number, which a String node would receive at Good. An ALARM: tag has no value, so its + /// data type is irrelevant; an unparseable address is left to the address check, which names it better. + /// + /// The authored data type. + /// The authored address. + /// The reason the pair cannot be served, or when it can. + public static string? DescribeDataTypeMismatch(FocasDataType dataType, string? address) + { + if (dataType != FocasDataType.String) return null; + var parsed = FocasAddress.TryParse(address); + if (parsed is null || parsed.Kind is FocasAreaKind.Fixed or FocasAreaKind.Alarm) return null; + return $"FOCAS data type 'String' is not readable at '{address!.Trim()}' — PMC, PARAM: and MACRO: reads " + + "return numbers (String is only valid on FIXED: paths such as FIXED:Identity/SeriesNumber). " + + "Choose Bit / Byte / Int16 / Int32 / Float32 / Float64."; + } + /// /// The tag's cross-driver alarm object, or when it carries none. /// Lenient like the host's TagConfigIntent: a non-integer severity is simply absent. @@ -236,6 +259,10 @@ public static class FocasTagDefinitionFactory } if (TagConfigJson.DescribeInvalidEnum(root, "dataType") is { } w) rejections.Add(w); + else if (addr.ValueKind == JsonValueKind.String + && TagConfigJson.TryReadEnumStrict(root, "dataType", FocasDataType.Int32, out var dataType) + && DescribeDataTypeMismatch(dataType, addr.GetString()) is { } mismatch) + rejections.Add(mismatch + " The driver will skip this tag and it will read BadNodeIdUnknown."); } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index cecc6299..bebe023a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -43,7 +43,41 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly Func? _rebind; private readonly PollGroupEngine _poll; private readonly ILogger _logger; - private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); + /// + /// Host address → device state. Built whole in and SWAPPED in (and swapped + /// out whole by a release), never cleared or refilled in place: the retained poll engine, the fixed-tree + /// and alarm loops and the actor's health tick all read it concurrently with a re-initialise, and a + /// mutated under an enumerator throws "Collection was modified". + /// Readers take one Volatile.Read snapshot and use it for the whole operation. + /// + private IReadOnlyDictionary _devices = EmptyDevices; + + private static readonly IReadOnlyDictionary EmptyDevices = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// The live device table (one consistent snapshot). + private IReadOnlyDictionary Devices => Volatile.Read(ref _devices); + + /// + /// Guards the publish/retire of the device table against an overlapping lifecycle call, and makes each + /// generation-checked health transition atomic (). Held only for synchronous swaps + /// and single health transitions — never across an await, and nothing is called under it. + /// + private readonly object _lifecycleLock = new(); + + /// + /// Bumped by every and . An initialise that finds + /// the counter moved on by the time it would publish has been superseded (the runtime abandoned it and + /// tore the driver down) and must start nothing. + /// + private int _lifecycleGeneration; + + /// + /// Completes when every device table released so far has finished its teardown. A shutdown that overlaps + /// another (Lane A's dispose beside a still-running shutdown) awaits it, so it never returns while the + /// first one is still joining loops and disposing clients. + /// + private Task _releaseTail = Task.CompletedTask; // v3: RawPath → definition, case-sensitive ordinal. Built in InitializeAsync from _options.RawTags // via the pure FocasTagDefinitionFactory mapper (the reference is always a RawPath). // Built whole in InitializeAsync and SWAPPED in: the retained poll engine keeps calling @@ -167,10 +201,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, internal void HandlePollError(Exception ex) { _logger.LogWarning(ex, "FOCAS poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); - var current = Volatile.Read(ref _health); - if (current.State != DriverState.Faulted) - Volatile.Write(ref _health, - new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message)); + // The generation the failed batch started under, when ReadAsync stamped it; otherwise the live one. + var generation = ex.Data.Contains(BatchGenerationKey) && ex.Data[BatchGenerationKey] is int g ? g : CurrentGeneration; + DegradeHealth(generation, ex.Message); } /// @@ -196,9 +229,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // release the previous device states' loops + clients BEFORE they are overwritten (a live driver can be // re-initialised without a teardown first). Not ShutdownAsync: that also drops _poll // and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init). + // + // The generation is taken FIRST: an initialise the runtime abandons (it timed out and moved on to a + // shutdown) is superseded by that shutdown's bump, and checks for it before publishing anything. + var generation = Interlocked.Increment(ref _lifecycleGeneration); await ReleaseDeviceStatesAsync().ConfigureAwait(false); - ResetAlarmHealth(); - Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null)); + WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Initializing, null, null)); + var devices = new Dictionary(StringComparer.OrdinalIgnoreCase); + // Set once this attempt's table is live: from then on it is the NEXT lifecycle call's to release, never + // this attempt's (review minor 1 — a reference compare could release a table twice). + var published = false; try { // adopt a changed config. Options and the backend client factory are re-derived by ONE @@ -222,7 +262,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var addr = FocasHostAddress.TryParse(device.HostAddress) ?? throw new InvalidOperationException( $"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'."); - _devices[device.HostAddress] = new DeviceState(addr, device); + devices[device.HostAddress] = new DeviceState(addr, device); } // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig // blob is mapped by the pure factory; the entry's WriteIdempotent flag + DeviceName are @@ -256,7 +296,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, throw new InvalidOperationException( $"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " + $"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname)."); - if (!_devices.TryGetValue(deviceHost, out var device)) + if (!devices.TryGetValue(deviceHost, out var device)) throw new InvalidOperationException( $"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " + $"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname)."); @@ -309,75 +349,141 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // above; reusing the parsed record avoids per-tick allocs on subscription pollers. parsedAddresses[entry.RawPath] = parsed; } - // Publish the skip tally only once the whole table is built. An unknown-device throw above - // leaves the previous snapshot standing rather than a truncated one that would read as - // "we now skip fewer tags". - _skippedTags.Commit(); - Volatile.Write(ref _parsedAddressesByTagName, parsedAddresses); - Volatile.Write(ref _tagsByRawPath, tagsByRawPath); - Volatile.Write(ref _alarmTags, alarmTags); - - if (_options.Probe.Enabled) + // Publish everything at once, and only if no later lifecycle call has superseded this one. The check + // and the publish share the lock with the shutdown's swap-out, so either the shutdown sees (and + // releases) this table, or this initialise sees the shutdown and publishes nothing. + lock (_lifecycleLock) { - foreach (var state in _devices.Values) - { - state.ProbeCts = new CancellationTokenSource(); - var ct = state.ProbeCts.Token; - state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct); - } - } + if (generation != Volatile.Read(ref _lifecycleGeneration)) + throw new OperationCanceledException( + $"FOCAS initialise of {_driverInstanceId} was superseded by a later shutdown/initialise; nothing was started."); - if (_options.HandleRecycle.Enabled) - { - foreach (var state in _devices.Values) - { - state.RecycleCts = new CancellationTokenSource(); - var ct = state.RecycleCts.Token; - state.RecycleTask = Task.Run(() => RecycleLoopAsync(state, ct), ct); - } - } - - // See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the - // config flag defaults off and the operator who committed those tags has already said - // what they want. - _fixedTreeActive = _options.FixedTree.Enabled - || _parsedAddressesByTagName.Values.Any(a => a.Kind == FocasAreaKind.Fixed); - if (_fixedTreeActive) - { - foreach (var state in _devices.Values) - { - state.FixedTreeCts = new CancellationTokenSource(); - var ct = state.FixedTreeCts.Token; - state.FixedTreeTask = Task.Run(() => FixedTreeLoopAsync(state, ct), ct); - } + // Publish the skip tally only once the whole table is built. An unknown-device throw above + // leaves the previous snapshot standing rather than a truncated one that would read as + // "we now skip fewer tags". + _skippedTags.Commit(); + // The per-path health of the previous table, reset only now that this attempt is known to be the + // live one (review minor 5): a superseded attempt must not wipe the health of the call that + // superseded it. + ResetAlarmHealth(); + _refusalWarned.Clear(); + Volatile.Write(ref _parsedAddressesByTagName, parsedAddresses); + Volatile.Write(ref _tagsByRawPath, tagsByRawPath); + Volatile.Write(ref _alarmTags, alarmTags); + // See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the + // config flag defaults off and the operator who committed those tags has already said + // what they want. + _fixedTreeActive = _options.FixedTree.Enabled + || parsedAddresses.Values.Any(a => a.Kind == FocasAreaKind.Fixed); + Volatile.Write(ref _devices, devices); + published = true; + StartDeviceLoops(devices.Values); } // Restart the alarm poll a re-initialise paused, against the tables just rebuilt. A // driver with no subscription yet starts nothing — the actor subscribes once it is Connected. await _alarmProjection.ResumeAsync().ConfigureAwait(false); + // A shutdown that landed while the resume ran owns the projection now: put it back the way it left it. + if (generation != Volatile.Read(ref _lifecycleGeneration)) + { + await _alarmProjection.PauseAsync().ConfigureAwait(false); + throw new OperationCanceledException( + $"FOCAS initialise of {_driverInstanceId} was superseded while resuming the alarm poll."); + } - Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + } + catch (OperationCanceledException) when (generation != Volatile.Read(ref _lifecycleGeneration)) + { + // Superseded: whatever this attempt built but never published is released here (a published table + // is the superseding call's to release); health belongs to that call too. + if (!published) await ReleaseTableAsync(devices.Values).ConfigureAwait(false); + throw; } catch (Exception ex) { - Volatile.Write(ref _health, new DriverHealth(DriverState.Faulted, null, ex.Message)); + WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, null, ex.Message)); throw; } } + /// Writes unless a later lifecycle call has taken over. + private void WriteHealthIfCurrent(int generation, DriverHealth health) + { + lock (_lifecycleLock) + { + if (generation == Volatile.Read(ref _lifecycleGeneration)) Volatile.Write(ref _health, health); + } + } + + /// Starts the probe / recycle / fixed-tree loops for a freshly built table. Caller holds the lifecycle lock. + private void StartDeviceLoops(IEnumerable states) + { + foreach (var state in states) + { + if (_options.Probe.Enabled) + { + state.ProbeCts = new CancellationTokenSource(); + var ct = state.ProbeCts.Token; + state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct); + } + if (_options.HandleRecycle.Enabled) + { + state.RecycleCts = new CancellationTokenSource(); + var ct = state.RecycleCts.Token; + state.RecycleTask = Task.Run(() => RecycleLoopAsync(state, ct), ct); + } + if (_fixedTreeActive) + { + state.FixedTreeCts = new CancellationTokenSource(); + var ct = state.FixedTreeCts.Token; + state.FixedTreeTask = Task.Run(() => FixedTreeLoopAsync(state, ct), ct); + } + } + } + /// - /// Cancels, bounded-joins and disposes every existing device state (probe / recycle / fixed-tree - /// loops + client) and the alarm projection, then clears the table. Device part of - /// only (no poll / subscription teardown). + /// Swaps the live device table out (to empty) and releases it: cancels, bounded-joins and disposes every + /// device state's loops and client, after pausing the alarm projection. Device part of + /// only (no poll / subscription teardown). Also waits for any release an + /// overlapping lifecycle call started earlier, so it never returns while that one is still running. /// private async Task ReleaseDeviceStatesAsync() { // Pause, not dispose: the subscription and what the server was told survive the rebuild. await _alarmProjection.PauseAsync().ConfigureAwait(false); - if (_devices.Count == 0) return; - var loops = new List(); - foreach (var state in _devices.Values) + IReadOnlyDictionary old; + Task prior; + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + lock (_lifecycleLock) { + old = _devices; + Volatile.Write(ref _devices, EmptyDevices); + prior = _releaseTail; + _releaseTail = done.Task; + } + try + { + if (old.Count > 0) await ReleaseTableAsync(old.Values).ConfigureAwait(false); + await prior.ConfigureAwait(false); + } + finally { done.TrySetResult(); } + } + + /// + /// Retires and tears down one swapped-out table: retire every device first (so a connect still in flight + /// disposes what it opens), cancel every loop, join them (bounded), then dispose the CTSs and — under each + /// device's connect gate — its client. + /// + private async Task ReleaseTableAsync(IEnumerable table) + { + var states = table.ToList(); + var loops = new List(); + foreach (var state in states) + { + state.Retire(); + // Cancel-then-dispose can race in tight shutdown loops; swallowing is intentional + // but we log the cause so a noisy shutdown leaves a Debug trace. try { state.ProbeCts?.Cancel(); } catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); } try { state.RecycleCts?.Cancel(); } @@ -388,15 +494,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, if (state.RecycleTask is { } rt) loops.Add(rt); if (state.FixedTreeTask is { } ft) loops.Add(ft); } + // Cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the + // client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could + // finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver. if (loops.Count > 0) { var all = Task.WhenAll(loops); var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false); if (finished != all) - _logger.LogWarning("FOCAS re-initialise: {Count} background loop(s) did not stop within {Timeout}; proceeding", + _logger.LogWarning("FOCAS teardown: {Count} background loop(s) did not stop within {Timeout}; proceeding", loops.Count, LoopJoinTimeout); } - foreach (var state in _devices.Values) + foreach (var state in states) { state.ProbeCts?.Dispose(); state.ProbeCts = null; @@ -407,9 +516,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, state.FixedTreeCts?.Dispose(); state.FixedTreeCts = null; state.FixedTreeTask = null; - state.DisposeClient(); + await DisposeClientGatedAsync(state).ConfigureAwait(false); } - _devices.Clear(); + } + + /// + /// Disposes a device's client under its , so it can neither null a + /// client a concurrent just installed nor race that connect. The wait + /// is bounded: a connect wedged past may not wedge teardown too (the device + /// is retired, so that connect disposes its own client when it finally returns). + /// + private async Task DisposeClientGatedAsync(DeviceState state) + { + var entered = await state.ConnectGate.WaitAsync(LoopJoinTimeout).ConfigureAwait(false); + try { state.DisposeClient(); } + catch (Exception ex) { _logger.LogDebug(ex, "FOCAS client dispose failed for {Host}", state.Options.HostAddress); } + finally { if (entered) state.ConnectGate.Release(); } } /// @@ -423,88 +545,63 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, internal static readonly TimeSpan LoopJoinTimeout = TimeSpan.FromSeconds(5); /// + // Safe to overlap with itself (Lane A disposes beside a still-running shutdown — the second call waits for + // the first's teardown via _releaseTail) and with an abandoned InitializeAsync (the generation bump below + // supersedes it, so it publishes nothing). public async Task ShutdownAsync(CancellationToken cancellationToken) { + var generation = Interlocked.Increment(ref _lifecycleGeneration); await _poll.DisposeAsync().ConfigureAwait(false); - // Pause, not dispose — see _alarmProjection. A later InitializeAsync resumes it. - await _alarmProjection.PauseAsync().ConfigureAwait(false); - // cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the - // client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could - // finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver. - var loops = new List(); - foreach (var state in _devices.Values) + // Pauses (not disposes) the alarm projection — see _alarmProjection; a later InitializeAsync resumes it. + await ReleaseDeviceStatesAsync().ConfigureAwait(false); + lock (_lifecycleLock) { - // 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. - try { state.ProbeCts?.Cancel(); } - catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); } - try { state.RecycleCts?.Cancel(); } - catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); } - try { state.FixedTreeCts?.Cancel(); } - catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); } - if (state.ProbeTask is { } pt) loops.Add(pt); - if (state.RecycleTask is { } rt) loops.Add(rt); - if (state.FixedTreeTask is { } ft) loops.Add(ft); + // A later initialise owns the tables (and health) now — leave them alone. + if (generation != Volatile.Read(ref _lifecycleGeneration)) return; + Volatile.Write(ref _tagsByRawPath, new Dictionary(StringComparer.Ordinal)); + Volatile.Write(ref _alarmTags, new Dictionary(StringComparer.Ordinal)); + Volatile.Write(ref _parsedAddressesByTagName, + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase)); + _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry + _fixedTreeActive = false; + ResetAlarmHealth(); + Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null)); } - if (loops.Count > 0) - { - var all = Task.WhenAll(loops); - var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false); - if (finished != all) - _logger.LogWarning("FOCAS shutdown: {Count} background loop(s) did not stop within {Timeout}; proceeding with teardown", - loops.Count, LoopJoinTimeout); - } - foreach (var state in _devices.Values) - { - state.ProbeCts?.Dispose(); - state.ProbeCts = null; - state.ProbeTask = null; - state.RecycleCts?.Dispose(); - state.RecycleCts = null; - state.RecycleTask = null; - state.FixedTreeCts?.Dispose(); - state.FixedTreeCts = null; - state.FixedTreeTask = null; - state.DisposeClient(); - } - _devices.Clear(); - Volatile.Write(ref _tagsByRawPath, new Dictionary(StringComparer.Ordinal)); - Volatile.Write(ref _alarmTags, new Dictionary(StringComparer.Ordinal)); - _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry - _parsedAddressesByTagName.Clear(); - _fixedTreeActive = false; - ResetAlarmHealth(); - Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null)); } /// - // The worst of the value path (_health) and the alarm path (_alarmTransportFailures), - // computed together. LastSuccessfulRead is the later of the two paths' last successes: while either - // path still reads, the comms-loss watchdog stays quiet; when both have stopped it is frozen. - // Faulted / Unknown / Initializing come from the value (lifecycle) path unchanged. + // The worst of the value path (_health), the alarm path (_alarmTransportFailures) and the fixed-tree path + // (_fixedTreeFailures), computed together. LastSuccessfulRead is the latest of the three paths' last + // successes: while any path still reads, the comms-loss watchdog stays quiet; when all have stopped it is + // frozen. Faulted / Unknown / Initializing come from the value (lifecycle) path unchanged. public DriverHealth GetHealth() { var value = Volatile.Read(ref _health); - var ticks = Interlocked.Read(ref _alarmLastSuccessTicks); - DateTime? alarmLast = ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc); - var last = value.LastSuccessfulRead is { } v && (alarmLast is null || v >= alarmLast) ? v : alarmLast; + var last = Latest(Latest(value.LastSuccessfulRead, Stamp(Interlocked.Read(ref _alarmLastSuccessTicks))), + Stamp(Interlocked.Read(ref _fixedTreeLastSuccessTicks))); if (value.State is not (DriverState.Healthy or DriverState.Degraded)) return value; - if (value.State == DriverState.Healthy && !_alarmTransportFailures.IsEmpty) - { - var reason = _alarmTransportFailures.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase).First().Value; + if (value.State == DriverState.Healthy + && (FirstFailure(_alarmTransportFailures) ?? FirstFailure(_fixedTreeFailures)) is { } reason) return new DriverHealth(DriverState.Degraded, last, reason); - } return value.LastSuccessfulRead == last ? value : value with { LastSuccessfulRead = last }; + + static DateTime? Stamp(long ticks) => ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc); + static DateTime? Latest(DateTime? a, DateTime? b) => a is null ? b : b is null || a >= b ? a : b; + static string? FirstFailure(ConcurrentDictionary failures) => failures.IsEmpty + ? null + : failures.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase).Select(kv => kv.Value).FirstOrDefault(); } /// Gets the number of configured devices. - internal int DeviceCount => _devices.Count; + internal int DeviceCount => Devices.Count; + /// Test seam — the live device table snapshot (swapped whole, never mutated in place). + internal IReadOnlyDictionary DeviceTable => Devices; /// Gets the state of a device by host address. /// The host address of the device. /// The device state if found; otherwise null. internal DeviceState? GetDeviceState(string hostAddress) => - _devices.TryGetValue(hostAddress, out var s) ? s : null; + Devices.TryGetValue(hostAddress, out var s) ? s : null; /// Test seam — the resolved options the factory built this driver from. internal FocasDriverOptions Options => _options; @@ -548,23 +645,25 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // ---- IReadable ---- /// + // Devices are read CONCURRENTLY, each one's tags in order on its own (serialised) socket: a slow CNC — a + // TCP-accept-then-stall control costs its Timeout per tag — used to hold every other device's read in the + // batch hostage, because the whole batch was one sequential walk. public async Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(fullReferences); var now = DateTime.UtcNow; var results = new DataValueSnapshot[fullReferences.Count]; + // The generation FIRST, then one snapshot of each swapped table for the whole call (see _devices): health + // written by this batch is dropped if a lifecycle call has moved on meanwhile. + var generation = CurrentGeneration; + var devices = Devices; + var alarmTags = Volatile.Read(ref _alarmTags); + var fixedTreeActive = _fixedTreeActive; - // Merge adjacent / near PMC reads of one device into fewer pmc_rdpmcrng calls. Indices it - // serves are marked here and skipped by the per-tag loop below; everything else (including every - // single-tag range and any range the CNC rejected) takes the unchanged per-tag path. - var served = _options.PmcCoalesce.Enabled - ? await CoalescePmcReadsAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false) - : null; - + Dictionary>? byDevice = null; for (var i = 0; i < fullReferences.Count; i++) { - if (served is not null && served[i]) continue; var reference = fullReferences[i]; // Fixed-tree T1 — fixed-tree references are synthesized from the cached @@ -574,63 +673,155 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // This branch matches a reference that IS a "{host}/{path}" fixed-tree key. Authored // tags do not take it — their reference is a RawPath — and are served further down by // their FIXED: address instead. - if (_fixedTreeActive && TryReadFixedTree(reference, now) is { } fx) + if (fixedTreeActive && TryReadFixedTree(devices, reference, now) is { } fx) { results[i] = fx; continue; } // An ALARM: tag is a Part 9 condition, not a value — there is nothing to read. - if (Volatile.Read(ref _alarmTags).ContainsKey(reference)) + if (alarmTags.ContainsKey(reference)) { results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNotSupported, null, now); continue; } + if (!_resolver.TryResolve(reference, out var def) + || !devices.TryGetValue(def.DeviceHostAddress, out var device)) + { + results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); + continue; + } + + byDevice ??= []; + if (!byDevice.TryGetValue(device, out var indices)) byDevice[device] = indices = []; + indices.Add(i); + } + + if (byDevice is null) return results; + try + { + if (byDevice.Count == 1) + { + foreach (var (device, indices) in byDevice) + await ReadDeviceAsync(device, indices, fullReferences, results, now, generation, cancellationToken).ConfigureAwait(false); + } + else + { + await Task.WhenAll(byDevice.Select(kv => + ReadDeviceAsync(kv.Key, kv.Value, fullReferences, results, now, generation, cancellationToken))).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Stamp the generation this batch STARTED under (review round 2, m3), so the poll engine's onError + // (HandlePollError) cannot degrade a generation that re-initialised after the batch began. + ex.Data[BatchGenerationKey] = generation; + throw; + } + return results; + } + + /// key carrying the lifecycle generation a failed batch started under. + internal const string BatchGenerationKey = "FocasDriver.BatchGeneration"; + + /// Test seam — the live lifecycle generation. + internal int LifecycleGeneration => CurrentGeneration; + + /// + /// Reads one device's share of a batch, in request order: the PMC coalescing pre-pass first, then every + /// index it did not serve through the per-tag path. Each result lands at its own index, so concurrent + /// device reads never touch the same slot. + /// + /// + /// One link failure ends the device's batch (#740 review I1). Concurrency alone does not bound a + /// batch: Task.WhenAll waits for the slowest device, and a CNC that accepts TCP and then stalls costs + /// the full call timeout on EVERY tag — so a 10-tag stalled device held the healthy devices' values back by + /// ten timeouts. After the first timeout / transport failure / connect failure on this device — i.e. NO + /// reply arrived — its remaining tags in the batch fail fast with BadCommunicationError, exactly as the coalescer's + /// FailMembers does for a merged range. Fixed-tree tags are still served from the cache: they need no + /// link. Any status the CNC RETURNED — a refusal, or a comms-classed rc such as EW_REJECT — does not end the + /// batch: a reply proves the link answered. + /// + private async Task ReadDeviceAsync( + DeviceState device, List indices, IReadOnlyList fullReferences, + DataValueSnapshot[] results, DateTime now, int generation, CancellationToken cancellationToken) + { + // Merge adjacent / near PMC reads of the device into fewer pmc_rdpmcrng calls. Indices it serves are + // marked here and skipped by the per-tag loop below; everything else (including every single-tag range + // and any range the CNC rejected) takes the unchanged per-tag path. + var (served, linkDown) = _options.PmcCoalesce.Enabled + ? await CoalescePmcReadsAsync(device, indices, fullReferences, results, now, generation, cancellationToken).ConfigureAwait(false) + : (null, false); + + foreach (var i in indices) + { + if (served is not null && served.Contains(i)) continue; + var reference = fullReferences[i]; if (!_resolver.TryResolve(reference, out var def)) { results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); continue; } - if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + + FocasAddress parsed; + try { parsed = ResolveParsedAddress(def); } + catch (InvalidOperationException) { + // A malformed address is a config fault of this tag, not of the link. results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); continue; } + // A FIXED: tag names a field of this device's fixed-tree snapshot, which the + // fixed-tree loop already maintains — there is no area read to issue, and no + // connection to force. A null here means the path resolves to nothing on THIS + // device (an unsupported capability, a renamed axis, a typo), which is a genuine + // BadNodeIdUnknown; a device that has not answered yet reports a comms fault + // instead, because the address may well be fine. + if (parsed.Kind == FocasAreaKind.Fixed) + { + results[i] = TryReadFixedTreeOnDevice(device, parsed.FixedPath!, now) + ?? new DataValueSnapshot( + null, + device.LastFixedPollUtc is null + ? FocasStatusMapper.BadCommunicationError + : FocasStatusMapper.BadNodeIdUnknown, + null, + now); + continue; + } + + if (linkDown) + { + // Already failed (and health already degraded) this batch — don't pay another timeout. + results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now); + continue; + } + try { - var parsed = ResolveParsedAddress(def); - - // A FIXED: tag names a field of this device's fixed-tree snapshot, which the - // fixed-tree loop already maintains — there is no area read to issue, and no - // connection to force. A null here means the path resolves to nothing on THIS - // device (an unsupported capability, a renamed axis, a typo), which is a genuine - // BadNodeIdUnknown; a device that has not answered yet reports a comms fault - // instead, because the address may well be fine. - if (parsed.Kind == FocasAreaKind.Fixed) - { - results[i] = TryReadFixedTreeOnDevice(device, parsed.FixedPath!, now) - ?? new DataValueSnapshot( - null, - device.LastFixedPollUtc is null - ? FocasStatusMapper.BadCommunicationError - : FocasStatusMapper.BadNodeIdUnknown, - null, - now); - continue; - } - var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false); results[i] = new DataValueSnapshot(value, status, now, now); if (status == FocasStatusMapper.Good) - Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null)); + { + _refusalWarned.TryRemove(reference, out _); + CreditHealth(generation, now); + } + else if (IsCommsStatus(status)) + { + // Degrade, but do NOT end the batch (#740 review round 2, N1): this status came back in a reply, + // so the CNC answered — EW_REJECT and every unlisted rc map to BadCommunicationError. Ending the + // batch here starved every later tag of a reachable control forever (authored order is fixed). + // A session that is really dead throws or times out on the next tag, which does end it. + DegradeHealth(generation, $"FOCAS status 0x{status:X8} reading {reference}"); + } else - Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, - Volatile.Read(ref _health).LastSuccessfulRead, - $"FOCAS status 0x{status:X8} reading {reference}")); + { + NoteRefusal(reference, def.Address, status, now, generation); + } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (OperationCanceledException) @@ -639,22 +830,84 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Timeout budget. Surface a recoverable comm error so the BadWaitingForInitialData // seed is overwritten and health degrades, instead of the read hanging forever. results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now); - Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, - Volatile.Read(ref _health).LastSuccessfulRead, $"FOCAS read timed out for {reference}")); + DegradeHealth(generation, $"FOCAS read timed out for {reference}"); + linkDown = true; } catch (Exception ex) { results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now); - Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, - Volatile.Read(ref _health).LastSuccessfulRead, ex.Message)); + DegradeHealth(generation, ex.Message); + linkDown = true; } } - - return results; } /// - /// PMC coalescing pre-pass. Groups the batch's PMC tags by device, plans merged ranges + /// Applies a data-path health transition — only while is still the live + /// lifecycle generation (#740 review minor 8): a read that started against the previous table must not + /// write its Degraded onto the next initialise's health. Taken under the lifecycle lock, so the check and + /// the write cannot straddle a publish; the lock is uncontended outside a lifecycle transition. + /// + /// The generation the caller's operation started under. + /// The transition; returns its input unchanged to leave health alone. + private void UpdateHealth(int generation, Func next) + { + lock (_lifecycleLock) + { + if (generation != Volatile.Read(ref _lifecycleGeneration)) return; + Volatile.Write(ref _health, next(Volatile.Read(ref _health))); + } + } + + /// A comms fault: Degraded with LastSuccessfulRead preserved; never downgrades Faulted. + private void DegradeHealth(int generation, string reason) => + UpdateHealth(generation, h => h.State == DriverState.Faulted + ? h + : new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, reason)); + + /// An answered read: Healthy with LastSuccessfulRead advanced. + private void CreditHealth(int generation, DateTime now) => + UpdateHealth(generation, _ => new DriverHealth(DriverState.Healthy, now, null)); + + /// The live lifecycle generation — captured by a data-path operation when it starts. + private int CurrentGeneration => Volatile.Read(ref _lifecycleGeneration); + + /// + /// RawPaths whose CNC refusal has already been logged — a refusal warns ONCE per tag, not once per poll, + /// and re-arms when the tag next reads Good. Cleared at initialise. + /// + private readonly ConcurrentDictionary _refusalWarned = new(StringComparer.Ordinal); + + /// + /// True for a status that says the LINK failed (or the read never got an answer), as opposed to the CNC + /// answering with a refusal. maps EW_SOCKET / EW_UNEXP / + /// EW_PARITY / unknown codes to BadCommunicationError and EW_HANDLE to BadInternalError; the wire client + /// reports a dead session as BadCommunicationError. + /// + private static bool IsCommsStatus(uint status) => status is FocasStatusMapper.BadCommunicationError + or FocasStatusMapper.BadInternalError or FocasStatusMapper.BadTimeout; + + /// + /// A deterministic CNC refusal (drivers.md health contract): per-tag Bad only — one Warning per tag, no + /// state change — but the CNC DID answer, so LastSuccessfulRead advances (else a reachable control whose + /// only tags are refused would look silent to the comms-loss watchdog). + /// + private void NoteRefusal(string reference, string address, uint status, DateTime now, int generation) + { + // The link answered, so a value-path Degraded left by an earlier comms fault is over (review minor 2): a + // device that recovers and then only refuses must not stay Degraded forever. Under the lifecycle lock + // (UpdateHealth), so concurrent device reads cannot interleave a read-modify-write. + UpdateHealth(generation, h => h.State is DriverState.Healthy or DriverState.Degraded + ? new DriverHealth(DriverState.Healthy, h.LastSuccessfulRead is { } l && l > now ? l : now, null) + : h); + if (_refusalWarned.TryAdd(reference, 0)) + _logger.LogWarning( + "FOCAS CNC refused {RawPath} ({Address}) with 0x{Status:X8}; the tag reads Bad until it is fixed (warned once). Driver={DriverInstanceId}", + reference, address, status, _driverInstanceId); + } + + /// + /// PMC coalescing pre-pass for ONE device's share of a batch. Plans merged ranges /// () and serves every multi-member range with ONE /// pmc_rdpmcrng, slicing each tag's value out of the returned slots. /// @@ -667,110 +920,109 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// leaves the members UNSERVED, so the per-tag loop reads them individually and each gets its own /// true status — and the range is remembered so later polls skip the doomed merged read. /// - /// Returns null when nothing was served. + /// Returns the served indices, or null when nothing was served. /// - private async Task CoalescePmcReadsAsync( - IReadOnlyList references, DataValueSnapshot[] results, DateTime now, CancellationToken ct) + private async Task<(HashSet? Served, bool LinkDown)> CoalescePmcReadsAsync( + DeviceState device, List indices, IReadOnlyList references, DataValueSnapshot[] results, + DateTime now, int generation, CancellationToken ct) { - Dictionary>? byDevice = null; - for (var i = 0; i < references.Count; i++) + List? items = null; + foreach (var i in indices) { try { - var reference = references[i]; - if (_fixedTreeActive && TryReadFixedTree(reference, now) is not null) continue; - if (!_resolver.TryResolve(reference, out var def)) continue; - if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue; + if (!_resolver.TryResolve(references[i], out var def)) continue; var parsed = ResolveParsedAddress(def); if (parsed.Kind != FocasAreaKind.Pmc || parsed.PmcLetter is null) continue; - (byDevice ??= []).TryAdd(device, []); - byDevice[device].Add(new FocasPmcReadItem(i, parsed.PmcLetter, parsed.Number, def.DataType, parsed.BitIndex)); + (items ??= []).Add(new FocasPmcReadItem(i, parsed.PmcLetter, parsed.Number, def.DataType, parsed.BitIndex)); } catch (Exception ex) when (ex is not OperationCanceledException) { /* malformed / unresolvable — the per-tag loop reports it with its usual status */ } } - if (byDevice is null) return null; + if (items is null || items.Count < 2) return (null, false); - bool[]? served = null; - foreach (var (device, items) in byDevice) + var plans = FocasPmcCoalescer.Plan(items, _options.PmcCoalesce.MaxSpanBytes, _options.PmcCoalesce.MaxGapBytes); + if (!plans.Any(p => p.Members.Count >= 2 && !device.CoalesceRejected.ContainsKey(p.Key))) return (null, false); + + HashSet? served = null; + var linkDown = false; + // One connect attempt per device per batch: if it fails, every PMC tag of the device fails with + // that status here instead of paying a connect attempt per plan and again per tag. + IFocasClient client; + try { client = await EnsureConnectedAsync(device, ct).ConfigureAwait(false); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception) { - if (items.Count < 2) continue; - var plans = FocasPmcCoalescer.Plan(items, _options.PmcCoalesce.MaxSpanBytes, _options.PmcCoalesce.MaxGapBytes); - if (!plans.Any(p => p.Members.Count >= 2 && !device.CoalesceRejected.ContainsKey(p.Key))) continue; + FailMembers(items, FocasStatusMapper.BadCommunicationError, + $"FOCAS connect failed reading PMC tags of {device.Options.HostAddress}"); + return (served, true); + } - // One connect attempt per device per batch: if it fails, every PMC tag of the device fails with - // that status here instead of paying a connect attempt per plan and again per tag. - IFocasClient client; - try { client = await EnsureConnectedAsync(device, ct).ConfigureAwait(false); } - catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } - catch (Exception) + foreach (var plan in plans) + { + if (plan.Members.Count < 2) continue; + if (device.CoalesceRejected.ContainsKey(plan.Key)) continue; + if (linkDown) { - FailMembers(items, FocasStatusMapper.BadCommunicationError, references, results, now, ref served, - $"FOCAS connect failed reading PMC tags of {device.Options.HostAddress}"); + // The link failed on an earlier range this batch: fail the rest without another timeout. + FailMembers(plan.Members, FocasStatusMapper.BadCommunicationError, + $"FOCAS transport failure on {device.Options.HostAddress} earlier in this batch"); continue; } - foreach (var plan in plans) + var members = plan.Members; + try { - if (plan.Members.Count < 2) continue; - if (device.CoalesceRejected.ContainsKey(plan.Key)) continue; + var (slots, status) = await client + .ReadPmcSlotsAsync(plan.Letter, plan.SlotType, plan.Start, plan.End, ct) + .ConfigureAwait(false); - var members = plan.Members; - try + if (status == FocasStatusMapper.Good && slots is not null) { - var (slots, status) = await client - .ReadPmcSlotsAsync(plan.Letter, plan.SlotType, plan.Start, plan.End, ct) - .ConfigureAwait(false); - - if (status == FocasStatusMapper.Good && slots is not null) + foreach (var m in members) { - foreach (var m in members) - { - var slot = plan.SlotOf(m); - results[m.Index] = slot < slots.Count - ? new DataValueSnapshot(FocasPmcCoalescer.MapSlot(slots[slot], m.Type, m.BitIndex), status, now, now) - : new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, now, now); - (served ??= new bool[references.Count])[m.Index] = true; - } - Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null)); - } - else - { - // The CNC REFUSED the merged range (any return code, mapped or not). Don't guess which - // member is at fault: let the per-tag loop give each its own answer. Remember the range - // so later polls skip the doomed read — except for a transient refusal (EW_BUSY maps to - // BadDeviceFailure), which says nothing about the range. - if (status != FocasStatusMapper.BadDeviceFailure) - device.CoalesceRejected[plan.Key] = 0; - _logger.LogDebug( - "FOCAS PMC range {Key} on {Host} rejected (0x{Status:X8}) — reading its {Count} tags individually", - plan.Key, device.Options.HostAddress, status, members.Count); + var slot = plan.SlotOf(m); + results[m.Index] = slot < slots.Count + ? new DataValueSnapshot(FocasPmcCoalescer.MapSlot(slots[slot], m.Type, m.BitIndex), status, now, now) + : new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, now, now); + (served ??= []).Add(m.Index); } + CreditHealth(generation, now); } - catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } - catch (Exception) + else { - // Transport failure / timeout: the link is suspect, so fail the members without per-tag retries. - FailMembers(members, FocasStatusMapper.BadCommunicationError, references, results, now, ref served, - $"FOCAS transport failure reading PMC range {plan.Key}"); + // The CNC REFUSED the merged range (any return code, mapped or not). Don't guess which + // member is at fault: let the per-tag loop give each its own answer. Remember the range + // so later polls skip the doomed read — except for a transient refusal (EW_BUSY maps to + // BadDeviceFailure), which says nothing about the range. + if (status != FocasStatusMapper.BadDeviceFailure) + device.CoalesceRejected[plan.Key] = 0; + _logger.LogDebug( + "FOCAS PMC range {Key} on {Host} rejected (0x{Status:X8}) — reading its {Count} tags individually", + plan.Key, device.Options.HostAddress, status, members.Count); } } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception) + { + // Transport failure / timeout: the link is suspect, so fail the members without per-tag retries. + FailMembers(members, FocasStatusMapper.BadCommunicationError, + $"FOCAS transport failure reading PMC range {plan.Key}"); + linkDown = true; + } } - return served; + return (served, linkDown); - void FailMembers(IEnumerable failed, uint status, IReadOnlyList refs, - DataValueSnapshot[] res, DateTime stamp, ref bool[]? servedRef, string reason) + void FailMembers(IEnumerable failed, uint status, string reason) { foreach (var m in failed) { - res[m.Index] = new DataValueSnapshot(null, status, null, stamp); - (servedRef ??= new bool[refs.Count])[m.Index] = true; + results[m.Index] = new DataValueSnapshot(null, status, null, now); + (served ??= []).Add(m.Index); } - Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, - Volatile.Read(ref _health).LastSuccessfulRead, - $"{reason} (0x{status:X8})")); + DegradeHealth(generation, $"{reason} (0x{status:X8})"); } } @@ -781,6 +1033,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, IReadOnlyList writes, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(writes); + var generation = CurrentGeneration; var results = new WriteResult[writes.Count]; for (var i = 0; i < writes.Count; i++) @@ -802,7 +1055,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, results[i] = new WriteResult(FocasStatusMapper.BadNotWritable); continue; } - if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + if (!Devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown); continue; @@ -831,14 +1084,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Per-call timeout (not external cancellation) — the write stalled past the device // Timeout budget. Surface a recoverable comm error rather than aborting the batch. results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError); - Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, - Volatile.Read(ref _health).LastSuccessfulRead, $"FOCAS write timed out for {w.FullReference}")); + DegradeHealth(generation, $"FOCAS write timed out for {w.FullReference}"); } catch (NotSupportedException nse) { + // A refusal, not a comms fault (drivers.md, #611): nothing was wrong with the link, so health is + // left alone — degrading here handed the comms-loss watchdog a reason to reconnect a healthy CNC. results[i] = new WriteResult(FocasStatusMapper.BadNotSupported); - Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, - Volatile.Read(ref _health).LastSuccessfulRead, nse.Message)); + _logger.LogDebug(nse, "FOCAS write to {RawPath} refused as unsupported", w.FullReference); } catch (Exception ex) when (ex is FormatException or InvalidCastException) { @@ -851,8 +1104,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, catch (Exception ex) { results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError); - Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded, - Volatile.Read(ref _health).LastSuccessfulRead, ex.Message)); + DegradeHealth(generation, ex.Message); } } @@ -890,7 +1142,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Fixed-tree T1 — Identity + Axes subtrees, populated once per session // from cnc_sysinfo + cnc_rdaxisname at init time and kept in DeviceState. if (_fixedTreeActive - && _devices.TryGetValue(device.HostAddress, out var state) + && Devices.TryGetValue(device.HostAddress, out var state) && state.FixedTreeCache is { } cache) { var identity = deviceFolder.Folder("Identity", "Identity"); @@ -1142,7 +1394,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// public IReadOnlyList GetHostStatuses() => - [.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))]; + [.. Devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))]; private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct) { @@ -1204,6 +1456,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Each optional call is attempted once; failures (EW_FUNC / EW_NOOPT / EW_VERSION) // record the capability as unsupported and suppress the corresponding nodes // in DiscoverAsync + the poll loop. + state.FixedTreeStartedUtc = DateTime.UtcNow; while (!ct.IsCancellationRequested && state.FixedTreeCache is null) { try @@ -1234,6 +1487,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // so stamp liveness here as well as per tick, or a CNC whose axis poll never // succeeds would serve a perfectly valid sysinfo as stale. state.LastFixedPollUtc = DateTime.UtcNow; + NoteFixedTreeAnswered(state); // Published LAST (release write): LastStatus is the field callers watch for "the first // answer is in", and an authored FIXED:Status/* read is only servable once // FixedTreeCache and LastFixedPollUtc are set (TryReadFixedTreeOnDevice). Publishing it @@ -1245,6 +1499,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, { _logger.LogDebug(ex, "FOCAS fixed-tree bootstrap failed for {Host} — retrying", state.Options.HostAddress); + NoteFixedTreeFailed(state, ex); try { await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); } catch (OperationCanceledException) { return; } } @@ -1293,6 +1548,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // of the loop: EnsureConnectedAsync can hand back a cached handle without any wire // traffic, so it alone does not prove the CNC is still there. state.LastFixedPollUtc = DateTime.UtcNow; + // …and it is the driver's evidence of life too: a fixed-only driver has no value read to credit. + NoteFixedTreeAnswered(state); // Servo loads + spindle loads — both return bulk arrays, so folding // into the axis cadence is cheap. Each is gated by the bootstrap @@ -1388,6 +1645,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /* next tick retries — transient blips are expected */ _logger.LogDebug(ex, "FOCAS fixed-tree poll tick failed for {Host}", state.Options.HostAddress); + // …but a sustained silence is a comms loss, and nothing else will report it for a fixed-only driver. + NoteFixedTreeFailed(state, ex); } try { await Task.Delay(_options.FixedTree.PollInterval, ct).ConfigureAwait(false); } @@ -1508,12 +1767,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// the reference looks like a fixed-tree FullName; null when it doesn't /// (callers fall through to the user-authored tag path). /// - private DataValueSnapshot? TryReadFixedTree(string reference, DateTime now) + private DataValueSnapshot? TryReadFixedTree( + IReadOnlyDictionary devices, string reference, DateTime now) { - foreach (var state in _devices.Values) + foreach (var state in devices.Values) { - if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue; - if (TryReadFixedTreeOnDevice(state, reference[(state.Options.HostAddress.Length + 1)..], now) is { } hit) + if (!reference.StartsWith(state.FixedTreePrefix, StringComparison.OrdinalIgnoreCase)) continue; + if (TryReadFixedTreeOnDevice(state, reference[state.FixedTreePrefix.Length..], now) is { } hit) return hit; } return null; @@ -1549,7 +1809,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, { var polledUtc = state.LastFixedPollUtc; if (polledUtc is null) return null; - var status = now - polledUtc.Value > FixedTreeStaleAfter(_options.FixedTree.PollInterval) + var status = now - polledUtc.Value > FixedTreeStaleBudget ? FocasStatusMapper.BadCommunicationError : FocasStatusMapper.Good; var sourceUtc = polledUtc.Value; @@ -1651,6 +1911,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, return null; } + /// + /// How long a handle recycle waits for in-flight ops on the detached client: one call timeout (each op is + /// bounded by it) plus slack, so a recycle never kills a read that would have completed. + /// + private TimeSpan RecycleDrainBudget => + (_options.Timeout > TimeSpan.Zero ? _options.Timeout : TimeSpan.FromSeconds(2)) + TimeSpan.FromMilliseconds(500); + private async Task RecycleLoopAsync(DeviceState state, CancellationToken ct) { while (!ct.IsCancellationRequested) @@ -1660,14 +1927,31 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Close the current handle — the next Read / Write / Probe call triggers // EnsureConnectedAsync, which reopens a fresh one. We don't block here on - // 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(); } + // reconnect because the goal is just to release the FWLIB handle slot. + // + // Under the device's ConnectGate, exactly like EnsureConnectedAsync's install: unserialised, the + // recycle could null a client a concurrent connect had just installed (orphaning a live two-socket + // session) or dispose one a poll was mid-read on (BadCommunicationError + Degraded every interval). + // The gate is held through the drain so no fresh session opens beside the old one — the CNC's + // handle pool is small — and the detached client is disposed only once the ops already holding it + // have finished (bounded by the call timeout). + try { await state.ConnectGate.WaitAsync(ct).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + try + { + var old = state.Client; + state.Client = null; + if (old is SynchronizedFocasClient sync) + await sync.DisposeWhenIdleAsync(RecycleDrainBudget).ConfigureAwait(false); + else + old?.Dispose(); + } catch (Exception ex) { /* already disposed or race — next EnsureConnected recovers */ _logger.LogDebug(ex, "FOCAS handle-recycle dispose failed for {Host}", state.Options.HostAddress); } + finally { state.ConnectGate.Release(); } } } @@ -1764,6 +2048,59 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, { _alarmTransportFailures.Clear(); Interlocked.Exchange(ref _alarmLastSuccessTicks, 0); + _fixedTreeFailures.Clear(); + Interlocked.Exchange(ref _fixedTreeLastSuccessTicks, 0); + } + + /// + /// The FIXED-TREE half of driver health: device host → why its fixed-tree loop has had no answer for longer + /// than the staleness budget. Kept apart from like the alarm path, and for the same + /// reason — a driver serving only FIXED: tags has no value read to degrade it, so before this it + /// read Healthy on /hosts with every node Bad and the #527 watchdog could never fire. + /// + private readonly ConcurrentDictionary _fixedTreeFailures = new(StringComparer.OrdinalIgnoreCase); + + /// UTC ticks of the last answered fixed-tree poll on any device (0 = none). + private long _fixedTreeLastSuccessTicks; + + /// Test seam: replaces the fixed-tree staleness budget (production derives it from the poll interval). + internal TimeSpan? FixedTreeStaleBudgetOverride { get; init; } + + /// + /// How long a device's fixed tree may go unanswered before its values read Bad AND the driver degrades — + /// one budget for both, so the node quality and the health surface cannot disagree. + /// + private TimeSpan FixedTreeStaleBudget => + FixedTreeStaleBudgetOverride ?? FixedTreeStaleAfter(_options.FixedTree.PollInterval); + + /// An answered fixed-tree pass of : clears its failure, advances LastSuccessfulRead. + private void NoteFixedTreeAnswered(DeviceState state) + { + if (state.IsRetired) return; + _fixedTreeFailures.TryRemove(state.Options.HostAddress, out _); + Interlocked.Exchange(ref _fixedTreeLastSuccessTicks, DateTime.UtcNow.Ticks); + } + + /// + /// A failed fixed-tree tick. A CNC REFUSAL (non-OK rc, transport fine) is not a comms loss: it clears any + /// failure AND credits the answer to LastSuccessfulRead (the CNC replied); a transport failure degrades the driver only once the device has been + /// silent past (measured from its last answer, or from loop start when + /// it never answered), so one dropped tick on a slow control does not flap /hosts. + /// + private void NoteFixedTreeFailed(DeviceState state, Exception error) + { + if (state.IsRetired) return; // a released device's late tick says nothing about the live table + if (FocasAlarmProjection.IsCncRefusal(error)) + { + // The CNC answered — evidence of life (review minor 2): clear the failure AND credit the answer, so a + // control that only refuses after an outage does not look silent to the comms-loss watchdog. + NoteFixedTreeAnswered(state); + return; + } + var since = state.LastFixedPollUtc ?? state.FixedTreeStartedUtc; + if (DateTime.UtcNow - since > FixedTreeStaleBudget) + _fixedTreeFailures[state.Options.HostAddress] = + $"FOCAS fixed-tree poll of {state.Options.HostAddress} has had no answer since {since:O}: {error.Message}"; } /// @@ -1776,7 +2113,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// The device's active alarms. internal async Task> ReadDeviceAlarmsAsync(string hostAddress, CancellationToken ct) { - if (!_devices.TryGetValue(hostAddress, out var state)) + if (!Devices.TryGetValue(hostAddress, out var state)) throw new InvalidOperationException($"FOCAS device '{hostAddress}' is not configured."); var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false); return await client.ReadAlarmsAsync(ct).ConfigureAwait(false); @@ -1822,6 +2159,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false); try { + // A released device (re-initialise / shutdown) is never reconnected: whoever still holds it read + // the table before the swap, and a client opened now would be orphaned on a state nobody tears down. + if (device.IsRetired) + throw new ObjectDisposedException(nameof(DeviceState), + $"FOCAS device {device.Options.HostAddress} was released by a re-initialise or shutdown."); + // Another caller may have connected while this one waited. if (device.Client is { IsConnected: true } connected) return connected; @@ -1864,6 +2207,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, throw; } device.Backoff.RecordSuccess(); // recovery resets the window immediately + // Retired WHILE this connect ran (the release waits on this gate only up to its bound): the connect + // is ours to undo, or it leaves a live session on a shut-down driver. + if (device.IsRetired) + { + client.Dispose(); + if (ReferenceEquals(device.Client, client)) device.Client = null; + throw new ObjectDisposedException(nameof(DeviceState), + $"FOCAS device {device.Options.HostAddress} was released while connecting."); + } return client; } finally { device.ConnectGate.Release(); } @@ -1928,6 +2280,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, public FocasHostAddress ParsedAddress { get; } = parsedAddress; /// Gets the device configuration options. public FocasDeviceOptions Options { get; } = options; + /// + /// "{HostAddress}/" — the prefix of this device's fixed-tree cache keys, built once rather than + /// per reference per poll (twice per poll before #740). + /// + public string FixedTreePrefix { get; } = options.HostAddress + "/"; /// Gets or sets the FOCAS client instance. public IFocasClient? Client { get; set; } @@ -1981,6 +2338,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// it falls outside . /// public DateTime? LastFixedPollUtc { get; set; } + + /// When this device's fixed-tree loop started — the silence origin for a device that never answered. + public DateTime FixedTreeStartedUtc { get; set; } = DateTime.UtcNow; /// /// Gets the last fixed-tree snapshots by field name. Double-typed so axis /// positions can carry the engineering-unit scale applied at @@ -2020,6 +2380,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// public ConcurrentDictionary LastSpindleMotorSpeeds { get; } = new(); + private int _retired; + + /// + /// True once a re-initialise / shutdown has released this device. A retired device never connects + /// again, and a connect that was already running when it retired disposes what it opened. + /// + public bool IsRetired => Volatile.Read(ref _retired) != 0; + + /// Marks the device released. Idempotent. + public void Retire() => Volatile.Write(ref _retired, 1); + /// Disposes the FOCAS client instance. public void DisposeClient() { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs index 9dc81fca..a0bade40 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs @@ -32,6 +32,9 @@ public sealed class SynchronizedFocasClient : IFocasClient private readonly IFocasClient _inner; private readonly TimeSpan _callTimeout; private readonly SemaphoreSlim _gate = new(1, 1); + /// Ops holding or queued for — what drains. + private int _pending; + private int _disposed; /// Wraps with per-device serialization + a per-call timeout. /// The underlying FOCAS client to serialize access to. @@ -115,26 +118,66 @@ public sealed class SynchronizedFocasClient : IFocasClient public Task> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetSpindleMotorSpeedsAsync(ct), cancellationToken); - /// Disposes the inner client and the call gate. + /// + /// Disposes the inner client. Ops still queued on the call gate then fail with + /// instead of running on a disposed socket. The gate itself is NOT + /// disposed: an op that already holds it releases it on the way out, and releasing a disposed + /// threw out of a read that had otherwise completed. + /// public void Dispose() { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; _inner.Dispose(); - _gate.Dispose(); + } + + /// + /// Disposes once every op that already holds (or is queued for) this client has finished, waiting at most + /// . Used by the handle-recycle loop, which detaches the client under the device's + /// connect gate first — so nothing new can reach it — and then must not kill a read that is mid-flight on + /// it (that read used to surface BadCommunicationError + Degraded on every recycle tick). + /// + /// Upper bound on the wait; the client is disposed regardless once it passes. + /// A task that completes when the client is disposed. + public async Task DisposeWhenIdleAsync(TimeSpan maxWait) + { + var deadline = DateTime.UtcNow + maxWait; + while (Volatile.Read(ref _pending) > 0 && DateTime.UtcNow < deadline) + await Task.Delay(5, CancellationToken.None).ConfigureAwait(false); + Dispose(); + } + + /// Gets the number of ops currently holding or queued for the call gate. Test seam. + internal int PendingOperations => Volatile.Read(ref _pending); + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(SynchronizedFocasClient), "The FOCAS client was recycled or disposed."); } // Gate only — the caller already governs the budget (connect timeout arg / probe linked token). private async Task RunGatedAsync(Func> op, CancellationToken ct) { - await _gate.WaitAsync(ct).ConfigureAwait(false); - try { return await op(ct).ConfigureAwait(false); } - finally { _gate.Release(); } + Interlocked.Increment(ref _pending); + try + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try { ThrowIfDisposed(); return await op(ct).ConfigureAwait(false); } + finally { _gate.Release(); } + } + finally { Interlocked.Decrement(ref _pending); } } private async Task RunGatedAsync(Func op, CancellationToken ct) { - await _gate.WaitAsync(ct).ConfigureAwait(false); - try { await op(ct).ConfigureAwait(false); } - finally { _gate.Release(); } + Interlocked.Increment(ref _pending); + try + { + await _gate.WaitAsync(ct).ConfigureAwait(false); + try { ThrowIfDisposed(); await op(ct).ConfigureAwait(false); } + finally { _gate.Release(); } + } + finally { Interlocked.Decrement(ref _pending); } } // Gate + per-call timeout. A fired timeout surfaces as OperationCanceledException whose token is @@ -142,16 +185,22 @@ public sealed class SynchronizedFocasClient : IFocasClient // their own token's IsCancellationRequested. private async Task RunBoundedAsync(Func> op, CancellationToken ct) { - await _gate.WaitAsync(ct).ConfigureAwait(false); + Interlocked.Increment(ref _pending); try { - if (_callTimeout <= TimeSpan.Zero) - return await op(ct).ConfigureAwait(false); + await _gate.WaitAsync(ct).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + if (_callTimeout <= TimeSpan.Zero) + return await op(ct).ConfigureAwait(false); - using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); - linked.CancelAfter(_callTimeout); - return await op(linked.Token).ConfigureAwait(false); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); + linked.CancelAfter(_callTimeout); + return await op(linked.Token).ConfigureAwait(false); + } + finally { _gate.Release(); } } - finally { _gate.Release(); } + finally { Interlocked.Decrement(ref _pending); } } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs index db407d18..fd76bf66 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs @@ -467,18 +467,41 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional path ID override; defaults to . /// The raw parameter payload bytes, wrapped in a . public async Task> ReadParameterBytesAsync( - short dataNumber, + int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { using var callTimeout = CreateCallTimeout(cancellationToken, timeout); - var secondArg = axis == 0 ? dataNumber : axis; - var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x000e, dataNumber, secondArg, PathId: EffectivePathId(pathId))).ConfigureAwait(false); + var block = await SendSingleRequestAsync(callTimeout.Token, ParameterRequest(dataNumber, axis, EffectivePathId(pathId))).ConfigureAwait(false); return ToResult(block, payload => payload); } + /// + /// The cnc_rdparam (0x000e) request block. The data number travels in the 32-bit + /// Arg1 UNNARROWED — the request block has always carried arguments, and + /// a short cast in the adapter once asked the CNC for a different (wrapped) number than the + /// one authored. A global parameter repeats the number in Arg2; an axis parameter carries + /// the axis there. + /// + /// The parameter data number. + /// Axis selector; zero means a global parameter. + /// The CNC path. + /// The request block. + internal static RequestBlock ParameterRequest(int dataNumber, short axis, ushort pathId) => + new(0x000e, dataNumber, axis == 0 ? dataNumber : axis, PathId: pathId); + + /// + /// The cnc_rdmacro (0x0015) request block: the variable number, unnarrowed, in both 32-bit + /// arguments (start / end of a one-variable range) — byte-identical to the live 31i-B capture for #500. + /// + /// The macro variable number. + /// The CNC path. + /// The request block. + internal static RequestBlock MacroRequest(int number, ushort pathId) => + new(0x0015, number, number, PathId: pathId); + /// Typed Int32 parameter read — convenience over . /// The parameter data number. /// The FOCAS parameter type code. @@ -487,7 +510,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional path ID override; defaults to . /// The typed parameter value, wrapped in a . public async Task> ReadParameterAsync( - short dataNumber, + int dataNumber, short type = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, @@ -507,7 +530,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional per-call timeout override. /// Optional path ID override; defaults to . /// The typed 8-bit parameter value, wrapped in a . - public async Task> ReadParameterByteAsync(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) + public async Task> ReadParameterByteAsync(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false); return !result.IsOk || result.Value is null @@ -522,7 +545,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional per-call timeout override. /// Optional path ID override; defaults to . /// The typed 16-bit parameter value, wrapped in a . - public async Task> ReadParameterInt16Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) + public async Task> ReadParameterInt16Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false); return !result.IsOk || result.Value is null @@ -537,7 +560,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional per-call timeout override. /// Optional path ID override; defaults to . /// The typed 32-bit parameter value, wrapped in a . - public async Task> ReadParameterInt32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) + public async Task> ReadParameterInt32Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false); return !result.IsOk || result.Value is null @@ -552,7 +575,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional per-call timeout override. /// Optional path ID override; defaults to . /// The typed single-precision parameter value, wrapped in a . - public async Task> ReadParameterFloat32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) + public async Task> ReadParameterFloat32Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false); return !result.IsOk || result.Value is null || result.Value.Length < 4 @@ -567,7 +590,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional per-call timeout override. /// Optional path ID override; defaults to . /// The typed double-precision parameter value, wrapped in a . - public async Task> ReadParameterFloat64Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) + public async Task> ReadParameterFloat64Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false); return !result.IsOk || result.Value is null || result.Value.Length < 8 @@ -582,14 +605,17 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional path ID override; defaults to . /// The macro variable value, wrapped in a . public Task> ReadMacroAsync( - short number, + int number, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) - => ReadSingleWithTimeoutAsync( - 0x0015, + { + var request = MacroRequest(number, EffectivePathId(pathId)); + return ReadSingleWithTimeoutAsync( + request.Command, payload => ParseMacro(number, payload), - cancellationToken, timeout, EffectivePathId(pathId), number, number); + cancellationToken, timeout, request.PathId, request.Arg1, request.Arg2); + } /// /// Decode a cnc_rdmacro (0x0015) payload. The on-wire ODBM record is 8 @@ -609,7 +635,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// The response-block payload. /// The decoded macro. /// The payload is shorter than 8 bytes. - internal static WireMacro ParseMacro(short number, byte[] payload) + internal static WireMacro ParseMacro(int number, byte[] payload) { RequireLength(payload, 8, "cnc_rdmacro"); return new WireMacro(number, ReadInt32(payload, 0), ReadInt16(payload, 6)); @@ -1111,15 +1137,88 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// The requested record count. /// The decoded alarms. /// The payload length matches neither record layout. + /// + /// Choosing the layout. The response block carries no record count, so a length that is a + /// multiple of BOTH strides (1,520 B = 20 × 76 = 19 × 80, and its multiples) is ambiguous; picking the + /// vendor stride on Length % 76 == 0 alone decoded 19 legacy records as 20 garbage ones. The choice + /// now uses what IS known: (1) the record count the REQUEST asked for — a reply never holds more, so a + /// layout implying more records than is ruled out; (2) each layout's own field + /// invariants, checked on every record ( / ). + /// Only when both readings remain plausible does the vendor layout win, as before. + /// internal static IReadOnlyList ParseAlarms(byte[] payload, short count) { - if (payload.Length % 76 == 0) return ParseVendorAlarms(payload, count); - if (payload.Length % 80 == 0) return ParseLegacyWireAlarms(payload, count); + if (payload.Length == 0) return []; + var vendor = payload.Length % VendorAlarmStride == 0 && payload.Length / VendorAlarmStride <= Math.Max((int)count, 1); + var legacy = payload.Length % LegacyAlarmStride == 0 && payload.Length / LegacyAlarmStride <= Math.Max((int)count, 1); + if (vendor && legacy) + { + // Ambiguous length: a legacy record's int32 type/axis/length fields must each fit the short they are + // read into and its length must fit the 64-byte message — a vendor payload read at the legacy stride + // fails that on its first non-empty message. Prefer the reading whose every record is self-consistent. + var legacyOk = LegacyRecordsValid(payload); + var vendorOk = VendorRecordsValid(payload); + if (legacyOk && !vendorOk) vendor = false; + else if (legacyOk && vendorOk && LengthsMatchText(payload, LegacyAlarmStride, 12, 4, 16) + && !LengthsMatchText(payload, VendorAlarmStride, 10, 2, 12)) vendor = false; + } + if (vendor) return ParseVendorAlarms(payload, count); + if (legacy) return ParseLegacyWireAlarms(payload, count); + if (payload.Length % VendorAlarmStride == 0 || payload.Length % LegacyAlarmStride == 0) + { + // Fits a stride but holds more records than were requested: fall back to the stride it fits, capped + // at the count, exactly as before this check existed. + return payload.Length % VendorAlarmStride == 0 + ? ParseVendorAlarms(payload, count) + : ParseLegacyWireAlarms(payload, count); + } throw new FocasWireException( $"cnc_rdalmmsg2 returned a {payload.Length}-byte payload, which fits neither the 76- nor the 80-byte " + "record layout; refusing to read it as an empty alarm list.", rc: null); } + private const int VendorAlarmStride = 76; + private const int LegacyAlarmStride = 80; + private const int AlarmMessageBytes = 64; + + /// Every 80-byte record's int32 type / axis / length fit a short, and the length fits the message. + private static bool LegacyRecordsValid(byte[] payload) + { + for (var o = 0; o + LegacyAlarmStride <= payload.Length; o += LegacyAlarmStride) + { + var type = ReadInt32(payload, o + 4); + var axis = ReadInt32(payload, o + 8); + var length = ReadInt32(payload, o + 12); + if (type is < short.MinValue or > short.MaxValue) return false; + if (axis is < short.MinValue or > short.MaxValue) return false; + if (length is < 0 or > AlarmMessageBytes) return false; + } + return true; + } + + /// Every 76-byte record's i16 message length fits the 64-byte message. + private static bool VendorRecordsValid(byte[] payload) + { + for (var o = 0; o + VendorAlarmStride <= payload.Length; o += VendorAlarmStride) + { + var length = ReadInt16(payload, o + 10); + if (length is < 0 or > AlarmMessageBytes) return false; + } + return true; + } + + /// Every record's declared message length equals the length of the text actually in its message field. + private static bool LengthsMatchText(byte[] payload, int stride, int lengthOffset, int lengthWidth, int messageOffset) + { + for (var o = 0; o + stride <= payload.Length; o += stride) + { + var length = lengthWidth == 4 ? ReadInt32(payload, o + lengthOffset) : ReadInt16(payload, o + lengthOffset); + var text = FocasWireProtocol.ReadAscii(payload.AsSpan(o + messageOffset, AlarmMessageBytes)); + if (length != text.Length) return false; + } + return true; + } + private static IReadOnlyList ParseVendorAlarms(byte[] payload, short count) { var alarms = new List(); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs index ac6f32ee..70ca55f6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs @@ -170,7 +170,7 @@ public readonly record struct WireSpindleRecord(short Index, string Name); /// Parameter value returned by cnc_rdparam, interpreted as a scalar Int32. public sealed record WireParameter( - short DataNumber, + int DataNumber, short Type, int Value); @@ -184,7 +184,7 @@ public sealed record WireParameter( /// The 9-significant-digit mantissa. /// Implied decimal places; negative (0xFFFF on the wire) = vacant. public sealed record WireMacro( - short Number, + int Number, int Value, short Decimal) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs index d6d5a3a4..ec44e6e3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs @@ -80,6 +80,7 @@ public sealed class WireFocasClient : IFocasClient var area = FocasPmcAreaLookup.FromLetter(letter); if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown); var dataType = FocasPmcDataTypeLookup.FromFocasDataType(slotType); + if (start < 0 || end > ushort.MaxValue || end < start) return (null, FocasStatusMapper.BadOutOfRange); try { var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, (ushort)start, (ushort)end, cancellationToken) @@ -334,18 +335,38 @@ public sealed class WireFocasClient : IFocasClient // ---- PMC / Parameter / Macro read paths ------------------------------------------ + /// + /// The 16-bit byte range pmc_rdpmcrng addresses for a slot of bytes at + /// . False when any byte of it falls outside 0..65535 — the range is refused + /// rather than wrapped onto a different PMC address (an Unknown-series device admits any number). + /// + /// The authored PMC byte address. + /// The slot width in bytes (≥ 1). + /// The first byte, when representable. + /// The last byte, when representable. + /// True when the whole slot is addressable. + internal static bool TryPmcRange(int number, int width, out ushort start, out ushort end) + { + start = end = 0; + var last = (long)number + Math.Max(width, 1) - 1; + if (number < 0 || last > ushort.MaxValue) return false; + start = (ushort)number; + end = (ushort)last; + return true; + } + private async Task<(object? value, uint status)> ReadPmcAsync( FocasAddress address, FocasDataType type, CancellationToken cancellationToken) { var area = FocasPmcAreaLookup.FromLetter(address.PmcLetter ?? string.Empty); if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown); var dataType = FocasPmcDataTypeLookup.FromFocasDataType(type); - var start = (ushort)address.Number; // pmc_rdpmcrng returns (end-start+1) BYTES. A multi-byte slot (Word/Long/Real/Double) needs // the range widened to its byte width, else the value parser gets too few bytes → 0 values // → spurious BadOutOfRange (live-confirmed on the 31i-B: a Word read with end=start returned // a single byte). Bit/Byte stay width 1 so end==start. 2026-06-25. - var end = (ushort)(start + FocasWireClient.PmcByteWidth((short)dataType) - 1); + if (!TryPmcRange(address.Number, FocasWireClient.PmcByteWidth((short)dataType), out var start, out var end)) + return (null, FocasStatusMapper.BadOutOfRange); try { @@ -373,23 +394,23 @@ public sealed class WireFocasClient : IFocasClient switch (type) { case FocasDataType.Byte: - var b = await _wire.ReadParameterByteAsync((short)address.Number, 0, cancellationToken).ConfigureAwait(false); + var b = await _wire.ReadParameterByteAsync(address.Number, 0, cancellationToken).ConfigureAwait(false); return b.IsOk ? ((object)(sbyte)b.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(b.Rc)); case FocasDataType.Int16: - var s = await _wire.ReadParameterInt16Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false); + var s = await _wire.ReadParameterInt16Async(address.Number, 0, cancellationToken).ConfigureAwait(false); return s.IsOk ? ((object)s.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(s.Rc)); case FocasDataType.Float32: - var f = await _wire.ReadParameterFloat32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false); + var f = await _wire.ReadParameterFloat32Async(address.Number, 0, cancellationToken).ConfigureAwait(false); return f.IsOk ? ((object)f.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(f.Rc)); case FocasDataType.Float64: - var d = await _wire.ReadParameterFloat64Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false); + var d = await _wire.ReadParameterFloat64Async(address.Number, 0, cancellationToken).ConfigureAwait(false); return d.IsOk ? ((object)d.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(d.Rc)); case FocasDataType.Bit when address.BitIndex is int bit: - var bi = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false); + var bi = await _wire.ReadParameterInt32Async(address.Number, 0, cancellationToken).ConfigureAwait(false); if (!bi.IsOk) return (null, FocasStatusMapper.MapFocasReturn(bi.Rc)); return ((object)((bi.Value >> bit & 1) != 0), FocasStatusMapper.Good); default: - var i = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false); + var i = await _wire.ReadParameterInt32Async(address.Number, 0, cancellationToken).ConfigureAwait(false); return i.IsOk ? ((object)i.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(i.Rc)); } } @@ -404,7 +425,7 @@ public sealed class WireFocasClient : IFocasClient { try { - var result = await _wire.ReadMacroAsync((short)address.Number, cancellationToken).ConfigureAwait(false); + var result = await _wire.ReadMacroAsync(address.Number, cancellationToken).ConfigureAwait(false); if (!result.IsOk || result.Value is null) return (null, FocasStatusMapper.MapFocasReturn(result.Rc)); return MapMacro(result.Value, type, address.BitIndex); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor index 8911b438..03fd6a15 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor @@ -15,8 +15,22 @@
+ @foreach (var v in Enum.GetValues()) + { + @* String is only readable from the fixed tree (FocasTagDefinitionFactory.DescribeDataTypeMismatch): + offered there, and kept visible when a loaded blob already carries it so the select never lies. *@ + if (v != FocasDataType.String || _m.DataType == FocasDataType.String + || FocasTagDefinitionFactory.DescribeDataTypeMismatch(v, _m.Address) is null) + { + + } + } + + @if (FocasTagDefinitionFactory.DescribeDataTypeMismatch(_m.DataType, _m.Address) is { } typeError) + { +
@typeError
+ } +
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs index 91851b8e..68904dff 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs @@ -80,6 +80,10 @@ public sealed class FocasTagConfigModel // ahead of the parse consult below and is never replaced by it. if (FocasAddress.TryParse(Address) is null) return DescribeUnparseableAddress(Address); + // The driver's own address/data-type rule (String only on FIXED: paths) — checked on the typed fields so a + // model built in the editor is judged too, not only a loaded blob the parse gate below can see. + if (FocasTagDefinitionFactory.DescribeDataTypeMismatch(DataType, Address) is { } typeError) return typeError; + // Parse-gate backstop. ⚠️ It gates on the PARSE VERDICT, never on Inspect being non-empty: FOCAS's // inspector emits advisories for blobs the reader ACCEPTS (an unparseable address, an authored // 'writable':true on a read-only wire), and promoting those to authoring errors would make the modal diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseValidationTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseValidationTests.cs index 2efff255..66dcabde 100644 --- a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseValidationTests.cs +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseValidationTests.cs @@ -33,6 +33,27 @@ public sealed class FocasCommandBaseValidationTests /// Invokes option validation without interval. public void InvokeValidateNoInterval() => ValidateOptions(intervalMs: null); + + /// Invokes the address/data-type check. + public void InvokeValidateDataType(string address, FocasDataType type) => ValidateDataType(address, type); + } + + /// + /// #740 / R07:U8 — --type String on a PMC / PARAM / MACRO address is refused at the CLI boundary + /// (the driver would skip the tag and the read would print BadNodeIdUnknown); a fixed-tree path keeps it. + /// + [Theory] + [InlineData("R100", FocasDataType.String, false)] + [InlineData("MACRO:500", FocasDataType.String, false)] + [InlineData("PARAM:6711", FocasDataType.String, false)] + [InlineData("R100", FocasDataType.Byte, true)] + [InlineData("FIXED:Identity/SeriesNumber", FocasDataType.String, true)] + public void Validate_data_type_against_address(string address, FocasDataType type, bool ok) + { + var sut = new Probe { CncHost = "host", IntervalMs = 1000 }; + if (ok) Should.NotThrow(() => sut.InvokeValidateDataType(address, type)); + else Should.Throw(() => sut.InvokeValidateDataType(address, type)) + .Message.ShouldContain("String"); } /// Verifies that default options are accepted. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs index 314fda86..ae0055b3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs @@ -72,6 +72,155 @@ public sealed class WireBackendCoverageTests } } + /// + /// #740 / R07:H14 — a macro / parameter number above 32767 reaches the CNC unnarrowed. The mock decodes + /// the request's 32-bit Arg1 as the number, so a decoy is seeded at the address the old + /// (short) cast produced (99999 → -31073, 40000 → -25536, as the unsigned key the mock sees): + /// reading the decoy's value means the number was wrapped. #500 / #6711 are the positive control that + /// ordinary numbers still read. + /// + [Fact] + public async Task Macro_and_parameter_numbers_above_short_range_read_the_authored_variable() + { + if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); + var ct = TestContext.Current.CancellationToken; + + static string Wrapped(int number) => ((uint)(int)(short)number).ToString(System.Globalization.CultureInfo.InvariantCulture); + + await _fx.LoadProfileAsync("FWLIB64", ct); + await _fx.PatchStateAsync(new + { + parameters = new Dictionary + { + ["6711"] = new { type = "long", value = 1234, @decimal = 0 }, + ["40000"] = new { type = "long", value = 4242, @decimal = 0 }, + [Wrapped(40000)] = new { type = "long", value = -1, @decimal = 0 }, + }, + macros = new Dictionary + { + ["500"] = new { value = 42000, @decimal = 3 }, + ["99999"] = new { value = 7, @decimal = 0 }, + [Wrapped(99999)] = new { value = 666, @decimal = 0 }, + }, + }, ct); + + var drv = new FocasDriver(new FocasDriverOptions + { + // ThirtyOne_i: the capability matrix admits #99999 (its documented ceiling); the parameter is read on + // an Unknown-series device, which admits any number. + Devices = [new FocasDeviceOptions(DeviceHost, Series: FocasCncSeries.ThirtyOne_i)], + RawTags = FocasRawTags.Entries( + new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false), + new FocasTagDefinition("Macro99999", DeviceHost, "MACRO:99999", FocasDataType.Int32, Writable: false), + new FocasTagDefinition("Param6711", DeviceHost, "PARAM:6711", FocasDataType.Int32, Writable: false)), + Probe = new FocasProbeOptions { Enabled = false }, + }, driverInstanceId: "wire-wide-numbers", clientFactory: new WireFocasClientFactory()); + + var unknownSeries = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(DeviceHost)], + RawTags = FocasRawTags.Entries( + new FocasTagDefinition("Param40000", DeviceHost, "PARAM:40000", FocasDataType.Int32, Writable: false)), + Probe = new FocasProbeOptions { Enabled = false }, + }, driverInstanceId: "wire-wide-param", clientFactory: new WireFocasClientFactory()); + + await using (drv) + await using (unknownSeries) + { + await drv.InitializeAsync("{}", ct); + var snaps = await drv.ReadAsync(["Macro500", "Macro99999", "Param6711"], ct); + snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good); + Convert.ToDouble(snaps[0].Value).ShouldBe(42.0, tolerance: 0.001); + snaps[1].Value.ShouldBe(7, "MACRO:99999 must read #99999, not the wrapped #-31073 decoy (666)"); + Convert.ToInt32(snaps[2].Value).ShouldBe(1234); + + await unknownSeries.InitializeAsync("{}", ct); + var param = await unknownSeries.ReadAsync(["Param40000"], ct); + param[0].StatusCode.ShouldBe(FocasStatusMapper.Good); + Convert.ToInt32(param[0].Value).ShouldBe(4242, "PARAM:40000 must not read the wrapped decoy (-1)"); + } + } + + /// + /// #740 / R07:H13 — re-initialising a live driver (the fixed-tree loop running, reads in flight over the real + /// wire) swaps the device table instead of clearing it under its readers: no reader throws, reads keep + /// returning Good, and shutdown leaves no session behind (a fresh driver can still connect afterwards). + /// + [Fact] + public async Task Reinitialise_under_live_reads_swaps_the_device_table() + { + if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); + var ct = TestContext.Current.CancellationToken; + + await _fx.LoadProfileAsync("FWLIB64", ct); + await _fx.PatchStateAsync(new + { + macros = new Dictionary { ["500"] = new { value = 42000, @decimal = 3 } }, + }, ct); + + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(DeviceHost)], + RawTags = FocasRawTags.Entries( + new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false)), + Probe = new FocasProbeOptions { Enabled = false }, + FixedTree = new FocasFixedTreeOptions + { + Enabled = true, + PollInterval = TimeSpan.FromMilliseconds(50), + ProgramPollInterval = TimeSpan.Zero, + TimerPollInterval = TimeSpan.Zero, + }, + }, driverInstanceId: "wire-reinit-swap", clientFactory: new WireFocasClientFactory()); + + await using (drv) + { + await drv.InitializeAsync("{}", ct); + using var stop = CancellationTokenSource.CreateLinkedTokenSource(ct); + var errors = new ConcurrentQueue(); + var good = 0; + var reader = Task.Run(async () => + { + while (!stop.IsCancellationRequested) + { + try + { + _ = drv.GetHostStatuses(); + var s = await drv.ReadAsync(["Macro500", $"{DeviceHost}/Identity/SeriesNumber"], CancellationToken.None); + if (s[0].StatusCode == FocasStatusMapper.Good) Interlocked.Increment(ref good); + } + catch (Exception ex) { errors.Enqueue(ex); } + } + }, CancellationToken.None); + + for (var i = 0; i < 5; i++) + { + await Task.Delay(150, ct); + await drv.InitializeAsync("{}", ct); + } + await Task.Delay(300, ct); + stop.Cancel(); + await reader; + + errors.ShouldBeEmpty(); + good.ShouldBeGreaterThan(0, "positive control: reads went over the wire between re-initialises"); + } + + // A fresh driver still connects and reads Good after the re-initialise churn and the shutdown. + var after = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(DeviceHost)], + RawTags = FocasRawTags.Entries( + new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false)), + Probe = new FocasProbeOptions { Enabled = false }, + }, driverInstanceId: "wire-reinit-after", clientFactory: new WireFocasClientFactory()); + await using (after) + { + await after.InitializeAsync("{}", ct); + (await after.ReadAsync(["Macro500"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.Good); + } + } + /// Verifies that discover emits device folder and tag variables. [Fact] public async Task Discover_emits_device_folder_and_tag_variables() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs new file mode 100644 index 00000000..c704b21f --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs @@ -0,0 +1,218 @@ +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series; + +/// +/// #740 fix round 1 (review I2) — wire-level cases for H15 (fixed-tree health) and H20 (alarm record layout) +/// against the focas-mock fixture. +/// +[Collection(FocasSimCollection.Name)] +public sealed class WireBackendHealthAndLayoutTests +{ + private readonly FocasSimFixture _fx; + + /// Initializes a new instance of the test class with the FOCAS simulation fixture. + /// The FOCAS simulation fixture. + public WireBackendHealthAndLayoutTests(FocasSimFixture fx) => _fx = fx; + + /// + /// A local TCP relay in front of the shared mock, so ONE test can take "the CNC" away and bring it back + /// without stopping a fixture other suites use: closes every relayed session and stops + /// listening (new connects are refused); listens again on the same port. + /// + private sealed class CncRelay : IAsyncDisposable + { + private readonly string _targetHost; + private readonly int _targetPort; + private readonly List _live = []; + private TcpListener? _listener; + private CancellationTokenSource? _cts; + + public CncRelay(string targetHost, int targetPort) + { + _targetHost = targetHost; + _targetPort = targetPort; + Port = Listen(0); + } + + public int Port { get; private set; } + + private int Listen(int port) + { + _cts = new CancellationTokenSource(); + _listener = new TcpListener(IPAddress.Loopback, port); + _listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + _listener.Start(); + var ct = _cts.Token; + var listener = _listener; + _ = Task.Run(() => AcceptLoopAsync(listener, ct), CancellationToken.None); + return ((IPEndPoint)_listener.LocalEndpoint).Port; + } + + private async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + TcpClient inbound; + try { inbound = await listener.AcceptTcpClientAsync(ct); } + catch { return; } + var outbound = new TcpClient(); + try { await outbound.ConnectAsync(_targetHost, _targetPort, ct); } + catch { inbound.Dispose(); outbound.Dispose(); continue; } + lock (_live) { _live.Add(inbound); _live.Add(outbound); } + _ = Pump(inbound, outbound, ct); + _ = Pump(outbound, inbound, ct); + } + } + + private static async Task Pump(TcpClient from, TcpClient to, CancellationToken ct) + { + try { await from.GetStream().CopyToAsync(to.GetStream(), ct); } + catch { /* the relay was taken down, or a side closed */ } + finally { from.Dispose(); to.Dispose(); } + } + + public void Down() + { + _cts?.Cancel(); + _listener?.Stop(); + lock (_live) + { + foreach (var c in _live) c.Dispose(); + _live.Clear(); + } + } + + /// + /// Listens again on the SAME port (the driver's host string names it). The re-bind is retried briefly: the + /// OS can hold the port for a moment after , or hand it to another process — the latter + /// surfaces as a clear failure after the retries rather than a random flake. + /// + public void Up() + { + for (var attempt = 1; ; attempt++) + { + try { Listen(Port); return; } + catch (SocketException) when (attempt < 20) { Thread.Sleep(100); } + } + } + + public ValueTask DisposeAsync() + { + Down(); + return ValueTask.CompletedTask; + } + } + + /// + /// H15 over the wire: a driver serving only FIXED: tags is Healthy with a moving LastSuccessfulRead + /// while its CNC answers; when the CNC disappears it degrades with LastSuccessfulRead frozen (the #527 + /// watchdog's conjunction); and it recovers to Healthy when the CNC returns (positive control). + /// + [Fact] + public async Task Fixed_only_driver_degrades_on_cnc_loss_and_recovers_over_the_wire() + { + if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); + var ct = TestContext.Current.CancellationToken; + await _fx.LoadProfileAsync("FWLIB64", ct); + + await using var relay = new CncRelay(_fx.Host, _fx.Port); + var host = $"focas://127.0.0.1:{relay.Port}"; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(host)], + RawTags = FocasRawTags.Entries( + new FocasTagDefinition("Series", host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: false)), + Probe = new FocasProbeOptions { Enabled = false }, + Timeout = TimeSpan.FromSeconds(1), + FixedTree = new FocasFixedTreeOptions + { + PollInterval = TimeSpan.FromMilliseconds(50), + ProgramPollInterval = TimeSpan.Zero, + TimerPollInterval = TimeSpan.Zero, + }, + }, driverInstanceId: "wire-fixed-health", clientFactory: new WireFocasClientFactory()) + { + FixedTreeStaleBudgetOverride = TimeSpan.FromMilliseconds(500), + }; + + await using (drv) + { + await drv.InitializeAsync("{}", ct); + await WaitFor(() => drv.GetDeviceState(host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(10)); + var first = drv.GetHealth().LastSuccessfulRead!.Value; + await WaitFor(() => drv.GetHealth().LastSuccessfulRead > first, TimeSpan.FromSeconds(5)); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + + relay.Down(); + await WaitFor(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(15)); + var frozen = drv.GetHealth().LastSuccessfulRead; + await Task.Delay(TimeSpan.FromSeconds(1), ct); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen, "nothing answered, so LastSuccessfulRead stays frozen"); + (await drv.ReadAsync(["Series"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError, + "the stale fixed-tree value reads Bad on the same budget that degrades the driver"); + + // Positive control: the CNC returns ⇒ Healthy again, LastSuccessfulRead moves. The connect backoff + // (1 s doubling) bounds how soon the loop re-dials. + relay.Up(); + await WaitFor(() => drv.GetHealth().State == DriverState.Healthy, TimeSpan.FromSeconds(40)); + drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(frozen!.Value); + (await drv.ReadAsync(["Series"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.Good); + } + } + + /// + /// H20 over the wire: the mock answers cnc_rdalmmsg2 in the 80-byte LEGACY layout. Nineteen active + /// alarms make a 1,520-byte payload — also exactly 20 × 76 — which the old length test decoded as twenty + /// garbage vendor records. Positive control: the unambiguous 3-alarm reply decodes the same alarms. + /// + [Fact] + public async Task Nineteen_legacy_alarms_decode_as_nineteen_over_the_wire() + { + if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); + var ct = TestContext.Current.CancellationToken; + await _fx.LoadProfileAsync("FWLIB64", ct); + + var client = new WireFocasClientFactory().Create(); + try + { + await client.ConnectAsync(FocasHostAddress.TryParse(_fx.DeviceUri)!, TimeSpan.FromSeconds(2), ct); + + await _fx.PatchStateAsync(new { alarms = Alarms(3) }, ct); + var three = await client.ReadAlarmsAsync(ct); + three.Count.ShouldBe(3); + three[2].AlarmNumber.ShouldBe(402); + + await _fx.PatchStateAsync(new { alarms = Alarms(19) }, ct); + var nineteen = await client.ReadAlarmsAsync(ct); + nineteen.Count.ShouldBe(19, "a 1,520-byte legacy reply is 19 records, not 20 vendor ones"); + nineteen.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(400, 19)); + nineteen[18].Message.ShouldBe("SERVO ALARM 418"); + nineteen[18].Axis.ShouldBe((short)(1 + (18 % 4))); + } + finally + { + client.Dispose(); + await _fx.PatchStateAsync(new { alarms = Array.Empty() }, ct); + } + + static object[] Alarms(int n) => [.. Enumerable.Range(0, n) + .Select(i => (object)new { alm_no = 400 + i, type = 2 + (i % 3), axis = 1 + (i % 4), msg = $"SERVO ALARM {400 + i}" })]; + } + + private static async Task WaitFor(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (!condition()) + { + if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached"); + await Task.Delay(25); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs index 00cf61d5..370bff15 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs @@ -259,7 +259,7 @@ internal sealed class FakeFocasClientFactory : IFocasClientFactory public IFocasClient Create() { var c = Customise?.Invoke() ?? new FakeFocasClient(); - Clients.Add(c); + lock (Clients) Clients.Add(c); return c; } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmLayoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmLayoutTests.cs new file mode 100644 index 00000000..467e72c3 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmLayoutTests.cs @@ -0,0 +1,114 @@ +using System.Buffers.Binary; +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// #740 / R07:H20 — ParseAlarms picked the 76-byte vendor layout whenever Length % 76 == 0, so a +/// legacy 80-byte reply of exactly 19 records (1,520 B = 20 × 76) decoded as 20 garbage vendor records. The +/// response block carries no record count, so the layout is now chosen from the two things that are known: the +/// count the REQUEST asked for (a reply never holds more) and each layout's own field invariants. +/// +[Trait("Category", "Unit")] +public sealed class FocasAlarmLayoutTests +{ + private static byte[] Legacy(params (int no, int type, int axis, string msg)[] records) + { + var buf = new byte[records.Length * 80]; + for (var i = 0; i < records.Length; i++) + { + var o = i * 80; + var (no, type, axis, msg) = records[i]; + BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no); + BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 4, 4), type); + BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 8, 4), axis); + BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 12, 4), msg.Length); + Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 16)); + } + return buf; + } + + private static byte[] Vendor(params (int no, short type, short axis, string msg)[] records) + { + var buf = new byte[records.Length * 76]; + for (var i = 0; i < records.Length; i++) + { + var o = i * 76; + var (no, type, axis, msg) = records[i]; + BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no); + BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 4, 2), type); + BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 6, 2), axis); + BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 10, 2), (short)msg.Length); + Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 12)); + } + return buf; + } + + private static (int, int, int, string)[] LegacySet(int n) => + [.. Enumerable.Range(0, n).Select(i => (400 + i, 2 + (i % 3), 1 + (i % 4), $"SERVO ALARM {400 + i}"))]; + + private static (int, short, short, string)[] VendorSet(int n) => + [.. Enumerable.Range(0, n).Select(i => (1000 + i, (short)(4 + (i % 3)), (short)(i % 2), $"OVERTRAVEL +{i}"))]; + + /// The ambiguous length: 19 legacy records decode as 19 legacy records, not 20 vendor ones. + [Fact] + public void Nineteen_legacy_records_decode_as_legacy() + { + var payload = Legacy(LegacySet(19)); + payload.Length.ShouldBe(1520); + (payload.Length % 76).ShouldBe(0, "the length really is ambiguous"); + + var alarms = FocasWireClient.ParseAlarms(payload, 32); + + alarms.Count.ShouldBe(19); + alarms[0].AlarmNumber.ShouldBe(400); + alarms[0].Message.ShouldBe("SERVO ALARM 400"); + alarms[18].AlarmNumber.ShouldBe(418); + alarms[18].Axis.ShouldBe((short)(1 + (18 % 4))); + } + + /// Positive control at the same length: 20 vendor records still decode as vendor. + [Fact] + public void Twenty_vendor_records_decode_as_vendor() + { + var payload = Vendor(VendorSet(20)); + payload.Length.ShouldBe(1520); + + var alarms = FocasWireClient.ParseAlarms(payload, 32); + + alarms.Count.ShouldBe(20); + alarms[0].AlarmNumber.ShouldBe(1000); + alarms[19].Message.ShouldBe("OVERTRAVEL +19"); + } + + /// + /// Review I2 edge: vendor records with type 0 and EMPTY messages also satisfy the legacy invariants (the + /// int32 read of type<<16 | axis is just the axis, and the "length" int32 is the empty message's + /// zero bytes) — both readings are plausible, so the vendor default must hold and the numbers decode. + /// + [Fact] + public void Twenty_vendor_records_with_type_zero_and_empty_messages_stay_vendor() + { + var payload = Vendor([.. Enumerable.Range(0, 20).Select(i => (2000 + i, (short)0, (short)(i % 3), ""))]); + payload.Length.ShouldBe(1520); + + var alarms = FocasWireClient.ParseAlarms(payload, 32); + + alarms.Count.ShouldBe(20); + alarms.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(2000, 20)); + alarms.ShouldAllBe(a => a.Type == 0 && a.Message == ""); + } + + /// The unambiguous lengths are unchanged. + [Fact] + public void Unambiguous_lengths_are_unchanged() + { + FocasWireClient.ParseAlarms(Vendor(VendorSet(3)), 32).Count.ShouldBe(3); + FocasWireClient.ParseAlarms(Legacy(LegacySet(3)), 32).Count.ShouldBe(3); + FocasWireClient.ParseAlarms([], 32).ShouldBeEmpty(); + Should.Throw(() => FocasWireClient.ParseAlarms(new byte[77], 32)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs index 9006936d..0749aafc 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs @@ -645,7 +645,8 @@ public sealed class FocasAlarmProjectionTests drv.GetHealth().State.ShouldBe(DriverState.Healthy); // Value path Degraded, alarm path good ⇒ Degraded: an alarm success cannot mask it. - factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange; + // A COMMS-class status: a CNC refusal (e.g. BadOutOfRange) is not a value-path Degraded since #740. + factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadCommunicationError; await drv.ReadAsync([valuePath], CancellationToken.None); drv.NoteAlarmReadSucceeded(Host); drv.GetHealth().State.ShouldBe(DriverState.Degraded); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConcurrentDeviceReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConcurrentDeviceReadTests.cs new file mode 100644 index 00000000..f4fa7d11 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConcurrentDeviceReadTests.cs @@ -0,0 +1,284 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// #740 / R07:P9 — walked the whole batch sequentially, so a SLOW CNC +/// (TCP-accept-then-stall, the live 31i-B case) cost its timeout per tag per poll during which no other +/// device was read. Devices are now read concurrently, each still serialised on its own socket. +/// +[Trait("Category", "Unit")] +public sealed class FocasConcurrentDeviceReadTests +{ + private const string HostA = "focas://10.0.0.5:8193"; + private const string HostB = "focas://10.0.0.6:8193"; + + /// + /// A client that records which device it dialled. Device A's read only completes once device B's read has + /// STARTED — so a sequential walk (A first) can never finish A, while a concurrent one does. Also counts + /// the peak number of reads in flight on any one client. + /// + private sealed class OrderingClient(TaskCompletionSource bStarted, int[] global) : FakeFocasClient + { + private string _host = ""; + private int _inFlight; + public int PeakInFlight; + public int Reads; + + public override Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) + { + _host = address.ToString(); + return base.ConnectAsync(address, timeout, ct); + } + + public override async Task<(object? value, uint status)> ReadAsync( + FocasAddress address, FocasDataType type, CancellationToken ct) + { + var now = Interlocked.Increment(ref _inFlight); + InterlockedMax(ref PeakInFlight, now); + Interlocked.Increment(ref Reads); + // global[0] = reads in flight across EVERY client, global[1] = its peak. + InterlockedMax(ref global[1], Interlocked.Increment(ref global[0])); + try + { + if (_host.Contains("10.0.0.6", StringComparison.Ordinal)) bStarted.TrySetResult(); + else await bStarted.Task.WaitAsync(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); + await Task.Delay(20, ct).ConfigureAwait(false); + return ((sbyte)1, FocasStatusMapper.Good); + } + finally + { + Interlocked.Decrement(ref _inFlight); + Interlocked.Decrement(ref global[0]); + } + } + + private static void InterlockedMax(ref int target, int value) + { + int seen; + while ((seen = Volatile.Read(ref target)) < value + && Interlocked.CompareExchange(ref target, value, seen) != seen) { } + } + } + + /// A stalled device no longer holds every other device's read hostage — and each device stays serialised. + [Fact] + public async Task Devices_are_read_concurrently_and_each_stays_serialised() + { + var bStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var global = new int[2]; + var factory = new FakeFocasClientFactory { Customise = () => new OrderingClient(bStarted, global) }; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")], + RawTags = + [ + FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)), + FocasRawTags.Entry(new FocasTagDefinition("raw/A2", "A", "R101", FocasDataType.Byte)), + FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R100", FocasDataType.Byte)), + ], + Probe = new FocasProbeOptions { Enabled = false }, + // Off so every tag takes the per-tag path — this pins device-level concurrency, not coalescing. + PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false }, + }, "focas-p9", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snaps = await drv.ReadAsync(["raw/A1", "raw/A2", "raw/B1"], CancellationToken.None); + + snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good, + "device A's read can only finish once device B's has started — a sequential walk times A out"); + factory.Clients.Count.ShouldBe(2); + var clients = factory.Clients.Cast().ToList(); + // Every client really served reads (2 on A, 1 on B), so "peak 1" is measured, not vacuous … + clients.Select(c => c.Reads).OrderBy(n => n).ShouldBe([1, 2]); + clients.ShouldAllBe(c => c.PeakInFlight == 1, + "one device's socket is still request→response: never two reads in flight on it"); + // … and the two devices overlapped: two reads were in flight at once across the clients. + global[1].ShouldBe(2, "the devices must be read at the same time, not one after the other"); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// A device that accepted TCP and then stopped answering: every read hangs until the call timeout. + private sealed class StalledClient : FakeFocasClient + { + public int Reads; + + public override async Task<(object? value, uint status)> ReadAsync( + FocasAddress address, FocasDataType type, CancellationToken ct) + { + Interlocked.Increment(ref Reads); + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return (null, FocasStatusMapper.Good); + } + } + + /// + /// #740 review I1 — Task.WhenAll waits for the slowest device, so concurrency alone still made a + /// stalled device cost (its tag count × the call timeout) per batch, and the healthy device's values waited + /// that long. After the first timeout on a device its remaining tags in the batch fail fast: the batch is + /// bounded by about ONE call timeout and the healthy device's values are Good. + /// + [Fact] + public async Task A_stalled_device_costs_one_call_timeout_per_batch_not_one_per_tag() + { + var callTimeout = TimeSpan.FromMilliseconds(200); + var factory = new FakeFocasClientFactory(); + factory.Customise = () => factory.Clients.Count == 0 ? new StalledClient() : new FakeFocasClient { Values = { ["R100"] = (sbyte)9 } }; + var stalledTags = Enumerable.Range(0, 10) + .Select(i => FocasRawTags.Entry(new FocasTagDefinition($"raw/A{i}", "A", $"R{100 + i}", FocasDataType.Byte))); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")], + RawTags = [.. stalledTags, FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R100", FocasDataType.Byte))], + Probe = new FocasProbeOptions { Enabled = false }, + PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false }, + Timeout = callTimeout, + }, "focas-stall", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + // Connect device A first so it gets the stalled client (connect order = client order). + await drv.ReadAsync(["raw/A0"], CancellationToken.None); + + var refs = Enumerable.Range(0, 10).Select(i => $"raw/A{i}").Append("raw/B1").ToArray(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + var snaps = await drv.ReadAsync(refs, CancellationToken.None); + sw.Stop(); + + sw.Elapsed.ShouldBeLessThan(callTimeout * 3, "one stalled device must cost ~one call timeout, not ten"); + snaps[10].StatusCode.ShouldBe(FocasStatusMapper.Good); + snaps[10].Value.ShouldBe((sbyte)9); + snaps.Take(10).ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError); + ((StalledClient)factory.Clients[0]).Reads.ShouldBe(2, "one read in the warm-up batch, one in the measured batch"); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// Counts per-tag reads; one address can be made to time out (throw) or answer a status. + private sealed class CountingClient : FakeFocasClient + { + public int Reads; + public string? TimeoutAt; + + public override async Task<(object? value, uint status)> ReadAsync( + FocasAddress address, FocasDataType type, CancellationToken ct) + { + Interlocked.Increment(ref Reads); + if (address.Canonical == TimeoutAt) + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + } + return await base.ReadAsync(address, type, ct).ConfigureAwait(false); + } + } + + private static FocasDriver OneDevice(FakeFocasClientFactory factory, bool coalesce, params string[] addresses) => new( + new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(HostA, DeviceName: "A")], + RawTags = [.. addresses.Select(a => FocasRawTags.Entry(new FocasTagDefinition($"raw/{a}", "A", a, FocasDataType.Byte)))], + Probe = new FocasProbeOptions { Enabled = false }, + PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = coalesce }, + Timeout = TimeSpan.FromMilliseconds(200), + }, "focas-linkdown", factory); + + /// + /// #740 review round 2 N1 — a status the CNC RETURNED (EW_REJECT and every unlisted rc map to + /// BadCommunicationError) proves the device answered, so it must not end the device's batch: the tags after it + /// still read. Before the fix, a deterministic rc on the first tag starved every later tag forever. + /// + [Fact] + public async Task A_returned_comms_status_does_not_fail_the_rest_of_the_batch() + { + var client = new CountingClient { Values = { ["R101"] = (sbyte)1, ["R102"] = (sbyte)2 } }; + client.ReadStatuses["R100"] = FocasStatusMapper.MapFocasReturn(13); // EW_REJECT ⇒ BadCommunicationError + var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: false, "R100", "R101", "R102"); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R102"], CancellationToken.None); + + snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); + snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good); + snaps[2].Value.ShouldBe((sbyte)2); + client.Reads.ShouldBe(3, "every tag was really read"); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// Positive control for N1: a TIMEOUT (no reply at all) still fails the rest of the batch without reading it. + [Fact] + public async Task A_timeout_still_fails_the_rest_of_the_batch_fast() + { + var client = new CountingClient { TimeoutAt = "R100", Values = { ["R101"] = (sbyte)1 } }; + var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: false, "R100", "R101", "R102"); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R102"], CancellationToken.None); + + snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError); + client.Reads.ShouldBe(1, "only the tag that timed out touched the wire"); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// + /// Review round 2 m5 — the coalescer's link-down paths: a transport fault on the first merged range fails the + /// second range WITHOUT a second range read, and the per-tag tags of the same device without a per-tag read. + /// + [Fact] + public async Task Coalescer_transport_fault_fails_later_ranges_and_per_tag_tags_without_reading_them() + { + var client = new CountingClient { ThrowOnPmcRange = true }; + // Two ranges far apart (gap > MaxGapBytes) + one lone tag that takes the per-tag path. + var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: true, + "R100", "R101", "R500", "R501", "D900"); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R500", "raw/R501", "raw/D900"], CancellationToken.None); + + snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError); + client.PmcRangeReads.Count.ShouldBe(1, "the second range must not pay another transport attempt"); + client.Reads.ShouldBe(0, "the per-tag tag must not pay one either"); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// Review round 2 m5 — a coalescer CONNECT failure fails the per-tag tags of the device without a second connect. + [Fact] + public async Task Coalescer_connect_failure_fails_per_tag_tags_without_reconnecting() + { + var factory = new FakeFocasClientFactory { Customise = () => new CountingClient { ThrowOnConnect = true } }; + var drv = OneDevice(factory, coalesce: true, "R100", "R101", "D900"); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/D900"], CancellationToken.None); + + snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError); + factory.Clients.Count.ShouldBe(1, "one connect attempt per device per batch"); + factory.Clients.Sum(c => c.ConnectCount).ShouldBe(1); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// Results land at their own indices whatever order the devices finish in. + [Fact] + public async Task Results_keep_their_request_order_across_devices() + { + var factory = new FakeFocasClientFactory(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")], + RawTags = + [ + FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)), + FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R200", FocasDataType.Byte)), + ], + Probe = new FocasProbeOptions { Enabled = false }, + }, "focas-p9-order", factory) + ; + factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1, ["R200"] = (sbyte)2 } }; + await drv.InitializeAsync("{}", CancellationToken.None); + + var snaps = await drv.ReadAsync(["raw/B1", "nope", "raw/A1"], CancellationToken.None); + + snaps[0].Value.ShouldBe((sbyte)2); + snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); + snaps[2].Value.ShouldBe((sbyte)1); + await drv.ShutdownAsync(CancellationToken.None); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDeviceTableLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDeviceTableLifecycleTests.cs new file mode 100644 index 00000000..056b7ca8 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDeviceTableLifecycleTests.cs @@ -0,0 +1,200 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// #740 / R07:H13 — the device table is read by the retained poll engine, the fixed-tree loop, the alarm +/// loop and the actor's health tick, so a re-initialise must build a fresh table and swap it in rather than +/// clearing and refilling the live one. Also pins the lifecycle overlaps the runtime produces since Lane A: +/// a dispose running beside a still-running shutdown, and an abandoned initialise overlapping the next +/// attempt's shutdown. +/// +[Trait("Category", "Unit")] +public sealed class FocasDeviceTableLifecycleTests +{ + private const string Host = "focas://10.0.0.5:8193"; + + private static FocasDriverOptions Options(bool probe = false, bool fixedTree = false) => new() + { + Devices = [new FocasDeviceOptions(Host)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R", Host, "R100", FocasDataType.Byte))], + Probe = new FocasProbeOptions { Enabled = probe, Interval = TimeSpan.FromMilliseconds(20) }, + FixedTree = new FocasFixedTreeOptions { Enabled = fixedTree, PollInterval = TimeSpan.FromMilliseconds(10) }, + }; + + /// A re-initialise publishes a NEW table; a reader holding the old one never sees it emptied. + [Fact] + public async Task Reinitialize_swaps_in_a_new_table_and_never_mutates_the_one_a_reader_holds() + { + var drv = new FocasDriver(Options(), "focas-swap", new FakeFocasClientFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + var before = drv.DeviceTable; + before.Count.ShouldBe(1); + + await drv.InitializeAsync("{}", CancellationToken.None); + + drv.DeviceTable.ShouldNotBeSameAs(before); + drv.DeviceTable.Count.ShouldBe(1); + before.Count.ShouldBe(1, "the table a concurrent reader enumerates must not be cleared under it"); + await drv.ShutdownAsync(CancellationToken.None); + before.Count.ShouldBe(1); + drv.DeviceTable.Count.ShouldBe(0); + } + + /// Hammering every table reader during repeated re-initialises throws nothing — and reads still succeed. + [Fact] + public async Task Readers_survive_repeated_reinitialise() + { + var factory = new FakeFocasClientFactory(); + var drv = new FocasDriver(Options(fixedTree: true), "focas-hammer", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + using var stop = new CancellationTokenSource(); + var good = 0; + var errors = new List(); + var reader = Task.Run(async () => + { + while (!stop.IsCancellationRequested) + { + try + { + _ = drv.GetHostStatuses(); + var snaps = await drv.ReadAsync(["raw/R", $"{Host}/Identity/SeriesNumber"], CancellationToken.None); + if (snaps[0].StatusCode == FocasStatusMapper.Good) Interlocked.Increment(ref good); + } + catch (Exception ex) { lock (errors) errors.Add(ex); } + } + }); + + for (var i = 0; i < 100; i++) + await drv.InitializeAsync("{}", CancellationToken.None); + await Task.Delay(100); + stop.Cancel(); + await reader; + await drv.ShutdownAsync(CancellationToken.None); + + errors.ShouldBeEmpty(); + good.ShouldBeGreaterThan(0, "positive control: the reader really did read through the table"); + } + + /// A client whose connect parks until released and ignores cancellation. + private sealed class ParkedConnectClient(TaskCompletionSource entered, Task release) : FakeFocasClient + { + public override async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) + { + entered.TrySetResult(); + await release.ConfigureAwait(false); + await base.ConnectAsync(address, timeout, CancellationToken.None).ConfigureAwait(false); + } + } + + /// + /// A data-path read parked in connect when shutdown runs must not leave a live client on the released + /// device: the device is retired, so the connect that completes afterwards disposes what it opened. + /// + [Fact] + public async Task Connect_completing_after_shutdown_does_not_leave_a_live_client() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var factory = new FakeFocasClientFactory { Customise = () => new ParkedConnectClient(entered, release.Task) }; + var drv = new FocasDriver(Options(), "focas-retire", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var read = drv.ReadAsync(["raw/R"], CancellationToken.None); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + var shutdown = drv.ShutdownAsync(CancellationToken.None); + await Task.Delay(50); + release.SetResult(); + await shutdown.WaitAsync(TimeSpan.FromSeconds(10)); + var snaps = await read.WaitAsync(TimeSpan.FromSeconds(5)); + + factory.Clients.Count.ShouldBe(1); + factory.Clients[0].IsConnected.ShouldBeFalse("a connect finishing on a retired device must dispose its client"); + factory.Clients[0].DisposeCount.ShouldBeGreaterThanOrEqualTo(1); + snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); + } + + /// + /// An initialise abandoned mid-way (the runtime timed it out and moved on to a shutdown) must not + /// resurrect the driver when it finally completes: no device table, no loops, no connection. + /// + [Fact] + public async Task Abandoned_initialise_finishing_after_shutdown_starts_nothing() + { + var factory = new FakeFocasClientFactory(); + var options = Options(probe: true); + using var gate = new ManualResetEventSlim(false); + var inRebind = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var drv = new FocasDriver(options, "focas-abandoned", factory, rebind: _ => + { + inRebind.TrySetResult(); + gate.Wait(TimeSpan.FromSeconds(10)); + return new FocasDriverBinding(options, factory); + }); + + var init = Task.Run(() => drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None)); + await inRebind.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await drv.ShutdownAsync(CancellationToken.None); + gate.Set(); + await Should.ThrowAsync(() => init.WaitAsync(TimeSpan.FromSeconds(10))); + + await Task.Delay(200); // several probe intervals — a started loop would have connected by now + drv.DeviceCount.ShouldBe(0); + factory.Clients.ShouldBeEmpty(); + drv.GetHealth().State.ShouldNotBe(DriverState.Healthy); + + // Positive control: the same driver, initialised without being superseded, does start its probe. + await drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None); + await WaitUntil(() => factory.Clients.Count > 0); + drv.DeviceCount.ShouldBe(1); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + await drv.DisposeAsync(); + } + + /// A loop wedged in a read that ignores cancellation for a while. + private sealed class SlowProbeClient : FakeFocasClient + { + public override async Task ProbeAsync(CancellationToken ct) + { + await Task.Delay(400, CancellationToken.None).ConfigureAwait(false); + return true; + } + } + + /// + /// Lane A disposes while its own shutdown may still be running. The dispose must not return before the + /// teardown it overlaps has released the client — it waits for it rather than finding an empty table. + /// + [Fact] + public async Task Dispose_concurrent_with_shutdown_waits_for_the_teardown() + { + var factory = new FakeFocasClientFactory { Customise = () => new SlowProbeClient() }; + var drv = new FocasDriver(Options(probe: true), "focas-dispose", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitUntil(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected); + + var shutdown = drv.ShutdownAsync(CancellationToken.None); + var dispose = drv.DisposeAsync().AsTask(); + await dispose.WaitAsync(TimeSpan.FromSeconds(10)); + + factory.Clients.ShouldAllBe(c => !c.IsConnected); + factory.Clients.ShouldAllBe(c => c.DisposeCount >= 1); + await shutdown.WaitAsync(TimeSpan.FromSeconds(10)); + drv.DeviceCount.ShouldBe(0); + } + + private static async Task WaitUntil(Func condition) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (!condition()) + { + if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached"); + await Task.Delay(10); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeHealthTests.cs new file mode 100644 index 00000000..ff5aef11 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeHealthTests.cs @@ -0,0 +1,163 @@ +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; + +/// +/// #740 / R07:H15 — the fixed-tree loop swallowed every failure at Debug and never touched driver health, so a +/// driver serving only FIXED: tags (Identity / Axes / Program / Status — the typical FOCAS shape) read +/// Healthy on /hosts with every node Bad, and the #527 comms-loss watchdog could never fire. The loop +/// now credits each answered axis pass (Healthy + LastSuccessfulRead) and, once a device has been silent past +/// the fixed-tree staleness budget, degrades the driver with LastSuccessfulRead frozen. +/// +[Trait("Category", "Unit")] +public sealed class FocasFixedTreeHealthTests +{ + private const string Host = "focas://10.0.0.5:8193"; + private static readonly TimeSpan Stale = TimeSpan.FromMilliseconds(300); + private static readonly TimeSpan BlipBudget = TimeSpan.FromMilliseconds(1500); + + /// A CNC that can be switched off (transport failure) or made to refuse the dynamic read. + private sealed class CncSwitch + { + public volatile bool Down; + public volatile bool Refuse; + /// Transport failures served so far — what the tests wait on instead of wall-clock delays. + public int Failures; + /// Refusals served so far. + public int Refusals; + } + + private sealed class SwitchableCnc(CncSwitch sw) : FakeFocasClient + { + private bool Down => sw.Down; + private bool Refuse => sw.Refuse; + + public override Task GetSysInfoAsync(CancellationToken ct) + { + if (!Down) return base.GetSysInfoAsync(ct); + Interlocked.Increment(ref sw.Failures); + throw new IOException("socket closed"); + } + + public override Task ReadDynamicAsync(int axisIndex, CancellationToken ct) + { + if (Down) + { + Interlocked.Increment(ref sw.Failures); + throw new IOException("socket closed"); + } + if (Refuse) + { + Interlocked.Increment(ref sw.Refusals); + throw new FocasWireException("cnc_rddynamic2 refused", rc: 6); + } + return base.ReadDynamicAsync(axisIndex, ct); + } + } + + private static FocasDriver FixedOnlyDriver(CncSwitch sw, TimeSpan? budget = null) => new(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/X", Host, "FIXED:Axes/X/AbsolutePosition", FocasDataType.Float64))], + Probe = new FocasProbeOptions { Enabled = false }, + FixedTree = new FocasFixedTreeOptions + { + PollInterval = TimeSpan.FromMilliseconds(20), + ProgramPollInterval = TimeSpan.Zero, + TimerPollInterval = TimeSpan.Zero, + }, + }, "focas-fixed-health", new FakeFocasClientFactory { Customise = () => new SwitchableCnc(sw) }) + { + FixedTreeStaleBudgetOverride = budget ?? Stale, + }; + + /// + /// Positive control first, then the outage: an answering CNC keeps the driver Healthy with a moving + /// LastSuccessfulRead; a CNC that stops answering degrades it once the budget passes, with + /// LastSuccessfulRead frozen (the watchdog's conjunction); and its return restores Healthy. + /// + [Fact] + public async Task Fixed_only_driver_degrades_when_its_cnc_disappears_and_recovers_when_it_returns() + { + var sw = new CncSwitch(); + // A wide budget (review round 2 m4): two 20 ms ticks always land well inside it, so the "blip does not + // degrade" absence check below runs unconditionally instead of being skipped on a slow agent. + await using var drv = FixedOnlyDriver(sw, BlipBudget); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitUntil(() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null); + + var first = drv.GetHealth().LastSuccessfulRead!.Value; + // An answered axis pass is a successful read: wait for it to move rather than sleeping. + await WaitUntil(() => drv.GetHealth().LastSuccessfulRead > first); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + + sw.Down = true; + var lastAnswer = drv.GetDeviceState(Host)!.LastFixedPollUtc!.Value; + // Two failed ticks have demonstrably happened (positive evidence for the absence check below) … + await WaitUntil(() => Volatile.Read(ref sw.Failures) >= 2); + // … inside the budget (asserted, not assumed), and the driver is still Healthy. + (DateTime.UtcNow - lastAnswer).ShouldBeLessThan(BlipBudget, "the two failed ticks must land inside the budget"); + drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a blip inside the staleness budget does not degrade"); + await WaitUntil(() => drv.GetHealth().State == DriverState.Degraded); + var frozen = drv.GetHealth().LastSuccessfulRead; + var failuresAtDegrade = Volatile.Read(ref sw.Failures); + await WaitUntil(() => Volatile.Read(ref sw.Failures) >= failuresAtDegrade + 5); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen, "LastSuccessfulRead must stay frozen while the CNC is silent"); + + sw.Down = false; + await WaitUntil(() => drv.GetHealth().State == DriverState.Healthy); + drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(frozen!.Value); + } + + /// A CNC that is down from the start (bootstrap never succeeds) degrades once the budget passes. + [Fact] + public async Task Bootstrap_that_never_succeeds_degrades_the_driver() + { + var sw = new CncSwitch { Down = true }; + await using var drv = FixedOnlyDriver(sw); + await drv.InitializeAsync("{}", CancellationToken.None); + // The bootstrap retries every 2 s, so the first failure is what has to trip it. + await WaitUntil(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(6)); + sw.Down = false; + } + + /// + /// A CNC that ANSWERS with a refusal is not a comms loss: the transport is fine, so the driver is not + /// degraded (else the watchdog would reconnect-loop a production control). + /// + [Fact] + public async Task Cnc_refusal_of_the_dynamic_read_does_not_degrade() + { + var sw = new CncSwitch(); + await using var drv = FixedOnlyDriver(sw); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitUntil(() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null); + + sw.Refuse = true; + var started = DateTime.UtcNow; + await WaitUntil(() => Volatile.Read(ref sw.Refusals) >= 1); + var atFirstRefusal = drv.GetHealth().LastSuccessfulRead!.Value; + // Refused ticks really happened, for longer than the staleness budget … + await WaitUntil(() => Volatile.Read(ref sw.Refusals) >= 5 && DateTime.UtcNow - started > Stale * 2); + // … and the driver stayed Healthy, with each refusal crediting the answer (review minor 2). + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(atFirstRefusal, + "a refusal is an answer from the CNC — LastSuccessfulRead advances"); + sw.Refuse = false; + } + + private static async Task WaitUntil(Func condition, TimeSpan? timeout = null) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5)); + while (!condition()) + { + if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached"); + await Task.Delay(10); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs index 97042231..4c287b4f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs @@ -62,6 +62,73 @@ public sealed class FocasHandleRecycleTests await drv.ShutdownAsync(CancellationToken.None); } + /// A client whose reads take a while — long enough for a recycle tick to land mid-read. + private sealed class SlowReadClient : FakeFocasClient + { + public override async Task<(object? value, uint status)> ReadAsync( + FocasAddress address, FocasDataType type, CancellationToken ct) + { + await Task.Delay(200, ct).ConfigureAwait(false); + if (!IsConnected) throw new ObjectDisposedException(nameof(SlowReadClient), "disposed mid-read"); + return ((sbyte)5, FocasStatusMapper.Good); + } + } + + /// + /// #740 / R07:L6 — the recycle loop disposed the device client with no gate, so a recycle tick landing + /// mid-read killed that read (BadCommunicationError + Degraded, every interval). The recycle now takes the + /// device's connect gate and lets the in-flight read finish before disposing the client it detached. + /// + [Fact] + public async Task Recycle_tick_landing_mid_read_lets_the_read_finish() + { + var factory = new FakeFocasClientFactory { Customise = () => new SlowReadClient() }; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))], + Probe = new FocasProbeOptions { Enabled = false }, + HandleRecycle = new FocasHandleRecycleOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Each 200 ms read spans at least three recycle ticks. + for (var i = 0; i < 3; i++) + { + var snap = (await drv.ReadAsync(["R"], CancellationToken.None))[0]; + snap.StatusCode.ShouldBe(FocasStatusMapper.Good, $"read {i} was killed by a recycle tick"); + snap.Value.ShouldBe((sbyte)5); + } + drv.GetHealth().State.ShouldBe(Core.Abstractions.DriverState.Healthy); + + // Positive control: the recycle really ran — the first client was disposed and a fresh one opened. + await WaitFor(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3)); + factory.Clients[0].DisposeCount.ShouldBeGreaterThan(0); + factory.Clients.Count.ShouldBeGreaterThan(1); + await drv.ShutdownAsync(CancellationToken.None); + factory.Clients.ShouldAllBe(c => !c.IsConnected, "no client opened around a recycle may be orphaned"); + } + + /// DisposeWhenIdleAsync drains the in-flight op, then disposes; a later op is refused, not run on a dead socket. + [Fact] + public async Task DisposeWhenIdle_drains_the_in_flight_op_then_refuses_new_ones() + { + var inner = new SlowReadClient(); + await inner.ConnectAsync(new FocasHostAddress("10.0.0.5", 8193), TimeSpan.FromSeconds(1), CancellationToken.None); + var sync = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(5)); + var addr = FocasAddress.TryParse("R100")!; + + var read = sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None); + await Task.Delay(20); + sync.PendingOperations.ShouldBe(1); + var dispose = sync.DisposeWhenIdleAsync(TimeSpan.FromSeconds(5)); + + (await read).status.ShouldBe(FocasStatusMapper.Good); + await dispose; + inner.DisposeCount.ShouldBe(1); + await Should.ThrowAsync(() => sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None)); + } + private static async Task WaitFor(Func pred, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs index f738c275..3e89a3ff 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs @@ -57,4 +57,32 @@ public sealed class FocasPollErrorHealthTests drv.GetHealth().State.ShouldBe(DriverState.Faulted); } + + /// + /// #740 review round 2 m3 — a poll failure carries the generation its batch STARTED under, so a batch that + /// began before a re-initialise cannot degrade the new generation. Positive control: a failure stamped with + /// the live generation (or unstamped) does degrade. + /// + [Fact] + public async Task PollReaderFailure_from_a_previous_generation_does_not_degrade_the_new_one() + { + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], + Probe = new FocasProbeOptions { Enabled = false }, + }, "focas-1", new FakeFocasClientFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + var oldGeneration = drv.LifecycleGeneration; + await drv.InitializeAsync("{}", CancellationToken.None); + + var stale = new InvalidOperationException("old batch boom"); + stale.Data[FocasDriver.BatchGenerationKey] = oldGeneration; + drv.HandlePollError(stale); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + + var live = new InvalidOperationException("live batch boom"); + live.Data[FocasDriver.BatchGenerationKey] = drv.LifecycleGeneration; + drv.HandlePollError(live); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadHealthContractTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadHealthContractTests.cs new file mode 100644 index 00000000..923ec354 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadHealthContractTests.cs @@ -0,0 +1,212 @@ +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// #740 driver-lane health checklist (drivers.md, #527/#611): a deterministic CNC refusal — the control +/// answered with a non-OK return code, or with a value the tag's type cannot hold (vacant macro, fractional +/// macro on an integer tag) — is per-tag Bad with ONE Warning per tag and NO health write, and it still +/// proves the device answered (LastSuccessfulRead advances). Only a comms-class status degrades the driver. +/// Before this, every non-Good status latched Degraded every poll, so one vacant macro made a reachable CNC +/// look sick forever. +/// +[Trait("Category", "Unit")] +public sealed class FocasReadHealthContractTests +{ + private const string Host = "focas://10.0.0.5:8193"; + + private sealed class WarningCounter : ILogger + { + private int _warnings; + public int Warnings => Volatile.Read(ref _warnings); + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning && formatter(state, exception).Contains("refused", StringComparison.Ordinal)) + Interlocked.Increment(ref _warnings); + } + } + + private static (FocasDriver drv, FakeFocasClient client, WarningCounter log) NewDriver(uint r100Status) + { + var client = new FakeFocasClient { Values = { ["R101"] = (sbyte)1 } }; + client.ReadStatuses["R100"] = r100Status; + var log = new WarningCounter(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + RawTags = + [ + FocasRawTags.Entry(new FocasTagDefinition("raw/R100", Host, "R100", FocasDataType.Byte)), + FocasRawTags.Entry(new FocasTagDefinition("raw/R101", Host, "R101", FocasDataType.Byte)), + ], + Probe = new FocasProbeOptions { Enabled = false }, + PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false }, + }, "focas-contract", new FakeFocasClientFactory { Customise = () => client }, log); + return (drv, client, log); + } + + /// A refusal leaves health alone, warns once, and still credits the answer. + [Theory] + [InlineData(FocasStatusMapper.BadOutOfRange)] // EW_NUMBER + [InlineData(FocasStatusMapper.BadNotSupported)] // EW_FUNC / EW_NOOPT + [InlineData(FocasStatusMapper.BadNoData)] // vacant macro + [InlineData(FocasStatusMapper.BadTypeMismatch)] // fractional macro on an integer tag + [InlineData(FocasStatusMapper.BadDeviceFailure)] // EW_BUSY + public async Task Cnc_refusal_is_per_tag_bad_with_one_warning_and_no_health_write(uint refusal) + { + var (drv, _, log) = NewDriver(refusal); + await drv.InitializeAsync("{}", CancellationToken.None); + var atInit = drv.GetHealth().LastSuccessfulRead; + + for (var i = 0; i < 3; i++) + { + await Task.Delay(5); + var snap = (await drv.ReadAsync(["raw/R100"], CancellationToken.None))[0]; + snap.StatusCode.ShouldBe(refusal); + } + + var health = drv.GetHealth(); + health.State.ShouldBe(DriverState.Healthy, "a CNC refusal is not a comms fault"); + health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(atInit!.Value, "the CNC answered — that is evidence of life"); + log.Warnings.ShouldBe(1, "one Warning per refused tag, not one per poll"); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// A refused tag that recovers and is refused again warns again (the dedup clears on Good). + [Fact] + public async Task Refusal_warning_rearms_after_the_tag_reads_good() + { + var (drv, client, log) = NewDriver(FocasStatusMapper.BadOutOfRange); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.ReadAsync(["raw/R100"], CancellationToken.None); + client.ReadStatuses.Remove("R100"); + client.Values["R100"] = (sbyte)3; + (await drv.ReadAsync(["raw/R100"], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good); + client.ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange; + await drv.ReadAsync(["raw/R100"], CancellationToken.None); + log.Warnings.ShouldBe(2); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// Positive control: a comms-class status still degrades, preserving LastSuccessfulRead. + [Fact] + public async Task Comms_status_still_degrades_with_last_successful_read_preserved() + { + var (drv, _, _) = NewDriver(FocasStatusMapper.BadCommunicationError); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.ReadAsync(["raw/R101"], CancellationToken.None); + var lastGood = drv.GetHealth().LastSuccessfulRead; + + await drv.ReadAsync(["raw/R100"], CancellationToken.None); + + var health = drv.GetHealth(); + health.State.ShouldBe(DriverState.Degraded); + health.LastSuccessfulRead.ShouldBe(lastGood); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// + /// Review minor 2 — a device that recovers from a comms fault and then only REFUSES (every tag it serves is + /// refused) is answering, so it is restored to Healthy instead of staying Degraded forever. + /// + [Fact] + public async Task Refusal_after_a_comms_fault_restores_healthy() + { + var (drv, client, _) = NewDriver(FocasStatusMapper.BadCommunicationError); + await drv.InitializeAsync("{}", CancellationToken.None); + await drv.ReadAsync(["raw/R100"], CancellationToken.None); + drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: the comms fault degraded it"); + + client.ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange; + await drv.ReadAsync(["raw/R100"], CancellationToken.None); + + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// A read whose CNC answer is held until released, then fails at the transport. + private sealed class HeldReadClient(TaskCompletionSource entered, Task release) : FakeFocasClient + { + public volatile bool Hold = true; + + public override async Task<(object? value, uint status)> ReadAsync( + FocasAddress address, FocasDataType type, CancellationToken ct) + { + if (!Hold) return await base.ReadAsync(address, type, ct).ConfigureAwait(false); + entered.TrySetResult(); + await release.ConfigureAwait(false); + throw new IOException("socket reset"); + } + } + + /// + /// Review minor 8 — a read that started against the PREVIOUS table and fails after a re-initialise must not + /// write its Degraded onto the new generation's health. Positive control: the same failure under the live + /// generation does degrade. + /// + [Fact] + public async Task A_read_from_the_previous_generation_cannot_degrade_the_new_one() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var clients = new List(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R100", Host, "R100", FocasDataType.Byte))], + Probe = new FocasProbeOptions { Enabled = false }, + Timeout = TimeSpan.FromSeconds(10), + }, "focas-gen", new FakeFocasClientFactory + { + Customise = () => { var c = new HeldReadClient(entered, release.Task); lock (clients) clients.Add(c); return c; }, + }); + await drv.InitializeAsync("{}", CancellationToken.None); + + var stale = drv.ReadAsync(["raw/R100"], CancellationToken.None); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await drv.InitializeAsync("{}", CancellationToken.None); // a new generation, Healthy + release.SetResult(); + (await stale.WaitAsync(TimeSpan.FromSeconds(5)))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); + + drv.GetHealth().State.ShouldBe(DriverState.Healthy, "the old generation's failure must not land on the new one"); + + // Positive control: a failure under the live generation degrades. + var live = drv.ReadAsync(["raw/R100"], CancellationToken.None); + (await live.WaitAsync(TimeSpan.FromSeconds(5)))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// A write the driver refuses (NotSupportedException) is not a comms fault either. + [Fact] + public async Task Write_refusal_does_not_degrade() + { + var client = new ThrowingWriteClient(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/W", Host, "R100", FocasDataType.Byte, Writable: true))], + Probe = new FocasProbeOptions { Enabled = false }, + }, "focas-write-refusal", new FakeFocasClientFactory { Customise = () => client }); + await drv.InitializeAsync("{}", CancellationToken.None); + + var result = await drv.WriteAsync([new WriteRequest("raw/W", (sbyte)1)], CancellationToken.None); + + result[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + await drv.ShutdownAsync(CancellationToken.None); + } + + private sealed class ThrowingWriteClient : FakeFocasClient + { + public override Task WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken ct) => + throw new NotSupportedException("bit writes are not supported on this area"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStringDataTypeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStringDataTypeTests.cs new file mode 100644 index 00000000..600a205a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStringDataTypeTests.cs @@ -0,0 +1,104 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// #740 / R07:U8 — was authorable on any address, but no area read +/// produces a string: PMC fell through to a byte read, PARAM to an Int32, MACRO to a double, and a +/// String-typed node received a boxed number at Good. String is now refused on PMC / PARAM / MACRO by the +/// one shared check, everywhere a tag is authored or loaded; the fixed tree (Identity / Program name, …), +/// whose values really are strings, keeps it. +/// +[Trait("Category", "Unit")] +public sealed class FocasStringDataTypeTests +{ + private const string Host = "focas://10.0.0.5:8193"; + + private static string Config(string address, string dataType) => + $$"""{"address":"{{address}}","dataType":"{{dataType}}"}"""; + + /// String on an area read is refused by the mapper. + [Theory] + [InlineData("R100")] + [InlineData("X0.0")] + [InlineData("PARAM:6711")] + [InlineData("MACRO:500")] + public void Mapper_refuses_string_on_an_area_read(string address) + { + FocasTagDefinitionFactory.FromTagConfig(Config(address, "String"), "raw/T", out _).ShouldBeFalse(); + FocasTagDefinitionFactory.DescribeDataTypeMismatch(FocasDataType.String, address).ShouldNotBeNull(); + } + + /// Positive control: numeric types on the same addresses, and String on the fixed tree, still map. + [Theory] + [InlineData("R100", "Byte")] + [InlineData("PARAM:6711", "Int32")] + [InlineData("MACRO:500", "Float64")] + [InlineData("FIXED:Identity/SeriesNumber", "String")] + [InlineData("FIXED:Program/Name", "String")] + public void Mapper_accepts_numeric_area_reads_and_string_fixed_tree_paths(string address, string dataType) + { + FocasTagDefinitionFactory.FromTagConfig(Config(address, dataType), "raw/T", out var def).ShouldBeTrue(); + def.Address.ShouldBe(address); + } + + /// An ALARM: tag has no value, so its data type is irrelevant and String is not refused. + [Fact] + public void Mapper_accepts_string_on_an_alarm_tag() + { + FocasTagDefinitionFactory.FromTagConfig( + """{"address":"ALARM:*","dataType":"String","alarm":{"severity":500}}""", "raw/A", out _).ShouldBeTrue(); + } + + /// An unparseable address is left to the address check — it still draws THAT diagnosis. + [Fact] + public void Unparseable_address_keeps_its_own_diagnosis() + { + FocasTagDefinitionFactory.DescribeDataTypeMismatch(FocasDataType.String, "not-an-address").ShouldBeNull(); + FocasTagDefinitionFactory.FromTagConfig(Config("not-an-address", "String"), "raw/T", out _).ShouldBeTrue(); + } + + /// The deploy-time inspector names the reason rather than falling to the generic backstop. + [Fact] + public void Inspect_names_the_string_refusal() + { + var warnings = FocasTagDefinitionFactory.Inspect(Config("MACRO:500", "String")); + warnings.ShouldContain(w => w.Contains("String", StringComparison.Ordinal) && w.Contains("MACRO:500", StringComparison.Ordinal)); + FocasTagDefinitionFactory.Inspect(Config("MACRO:500", "Float64")).ShouldBeEmpty(); + } + + /// + /// At init a String area tag is skipped and reported (never served as a Good number); the numeric tag + /// beside it reads normally (positive control). + /// + [Fact] + public async Task Driver_skips_and_reports_a_string_area_tag() + { + var factory = new FakeFocasClientFactory + { + Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } }, + }; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], + RawTags = + [ + new RawTagEntry("raw/Str", Config("R100", "String"), false, Host), + new RawTagEntry("raw/Num", Config("R100", "Byte"), false, Host), + ], + Probe = new FocasProbeOptions { Enabled = false }, + }, "focas-u8", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snaps = await drv.ReadAsync(["raw/Str", "raw/Num"], CancellationToken.None); + snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown); + snaps[0].Value.ShouldBeNull(); + snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good); + snaps[1].Value.ShouldBe((sbyte)7); + drv.GetSkippedTags()!.Sample.ShouldContain(s => s.RawPath == "raw/Str"); + await drv.ShutdownAsync(CancellationToken.None); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWideNumberTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWideNumberTests.cs new file mode 100644 index 00000000..7bf63a40 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWideNumberTests.cs @@ -0,0 +1,84 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// #740 / R07:H14 — the capability matrix admits macro numbers up to 99999 (30i/31i/32i) and parameters up +/// to 29999 (Unknown: anything), and the FOCAS/2 request block carries 32-bit arguments, but the adapter +/// cast every number to short: MACRO:99999 asked the CNC for #-31073 and published +/// that variable Good under the authored tag. Numbers now travel as int end to end. +/// +[Trait("Category", "Unit")] +public sealed class FocasWideNumberTests +{ + /// The macro request carries the full number in both 32-bit argument slots. + [Theory] + [InlineData(500)] + [InlineData(32767)] + [InlineData(32768)] + [InlineData(99999)] + public void Macro_request_carries_the_unnarrowed_number(int number) + { + var block = FocasWireClient.MacroRequest(number, pathId: 1); + block.Command.ShouldBe((ushort)0x0015); + block.Arg1.ShouldBe(number); + block.Arg2.ShouldBe(number); + } + + /// + /// Positive control on the wire encoding: the request for #500 is byte-identical to the one the real + /// 31i-B answered in the ref-macro-500-0x0015 capture, so widening changed nothing below 32768. + /// + [Fact] + public void Macro_request_for_500_matches_the_live_capture_bytes() + { + var body = FocasWireProtocol.BuildRequestBody([FocasWireClient.MacroRequest(500, pathId: 1)]); + Convert.ToHexString(body).ShouldBe("0001001C0001000100150000 01F4000001F4000000000000000000000000".Replace(" ", "")); + } + + /// A global parameter puts its number in both arguments; an axis parameter puts the axis second. + [Theory] + [InlineData(6711, (short)0, 6711)] + [InlineData(40000, (short)0, 40000)] + [InlineData(40000, (short)2, 2)] + public void Parameter_request_carries_the_unnarrowed_number(int number, short axis, int expectedArg2) + { + var block = FocasWireClient.ParameterRequest(number, axis, pathId: 1); + block.Command.ShouldBe((ushort)0x000e); + block.Arg1.ShouldBe(number); + block.Arg2.ShouldBe(expectedArg2); + } + + /// The decoded macro reports the number that was asked for, not a wrapped one. + [Fact] + public void Parsed_macro_keeps_a_number_above_short_range() + { + var payload = new byte[] { 0x05, 0xF5, 0xE1, 0x00, 0x00, 0x0A, 0x00, 0x08 }; + var m = FocasWireClient.ParseMacro(99999, payload); + m.Number.ShouldBe(99999); + m.Scaled.ShouldBe(1.0); + } + + /// + /// pmc_rdpmcrng addresses are 16-bit on the wire; a PMC number whose slot runs past 65535 is refused + /// rather than wrapped onto a different address (Unknown series admits any number). + /// + [Theory] + [InlineData(100, 1, true)] + [InlineData(65535, 1, true)] + [InlineData(65534, 2, true)] + [InlineData(65535, 2, false)] + [InlineData(70000, 1, false)] + public void Pmc_range_outside_16_bits_is_refused_not_wrapped(int number, int width, bool ok) + { + WireFocasClient.TryPmcRange(number, width, out var start, out var end).ShouldBe(ok); + if (ok) + { + ((int)start).ShouldBe(number); + ((int)end).ShouldBe(number + width - 1); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/FocasTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/FocasTagConfigModelTests.cs index 334637cd..b3f16e8b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/FocasTagConfigModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/FocasTagConfigModelTests.cs @@ -169,4 +169,26 @@ public sealed class FocasTagConfigModelTests [InlineData("""{"address":"R100","dataType":2}""")] public void Validate_accepts_every_dataType_the_driver_accepts(string json) => FocasTagConfigModel.FromJson(json).Validate().ShouldBeNull(); + + /// + /// #740 / R07:U8 — String on a PMC / PARAM / MACRO address is refused by the driver (no area read + /// returns text), so the modal refuses it too, for a model built in the editor and for a loaded blob. + /// + [Theory] + [InlineData("R100")] + [InlineData("PARAM:6711")] + [InlineData("MACRO:500")] + public void Validate_refuses_string_on_an_area_address(string address) + { + new FocasTagConfigModel { Address = address, DataType = FocasDataType.String } + .Validate().ShouldNotBeNull().ShouldContain("String"); + FocasTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"String"}""") + .Validate().ShouldNotBeNull().ShouldContain("String"); + } + + /// Positive control: the fixed tree's string fields keep String. + [Fact] + public void Validate_accepts_string_on_a_fixed_tree_path() + => new FocasTagConfigModel { Address = "FIXED:Identity/SeriesNumber", DataType = FocasDataType.String } + .Validate().ShouldBeNull(); }