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