Merge lane FOCAS: FOCAS driver remediation (#740)

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:01:43 -04:00
28 changed files with 2568 additions and 299 deletions
@@ -28,6 +28,7 @@ public sealed class ProbeCommand : FocasCommandBase
{
ConfigureLogging();
ValidateOptions();
ValidateDataType(Address, DataType);
var ct = console.RegisterCancellationHandler();
var probeTag = new FocasTagDefinition(
@@ -19,7 +19,7 @@ public sealed class ReadCommand : FocasCommandBase
/// <summary>Gets the data type to interpret the address as.</summary>
[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;
/// <inheritdoc />
@@ -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);
@@ -18,7 +18,7 @@ public sealed class SubscribeCommand : FocasCommandBase
/// <summary>Gets the data type of the address.</summary>
[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;
/// <summary>Gets the polling interval in milliseconds.</summary>
@@ -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);
@@ -20,7 +20,7 @@ public sealed class WriteCommand : FocasCommandBase
/// <summary>Gets the data type of the value to write.</summary>
[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;
/// <summary>Gets the value to write.</summary>
@@ -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);
@@ -89,4 +89,17 @@ public abstract class FocasCommandBase : DriverCommandBase
throw new CliFx.Exceptions.CommandException(
$"--interval-ms must be positive (got {iv}).");
}
/// <summary>
/// Refuses an <c>--address</c> / <c>--type</c> 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: <see cref="FocasTagDefinitionFactory.DescribeDataTypeMismatch"/>.
/// </summary>
/// <param name="address">The FOCAS address.</param>
/// <param name="type">The data type.</param>
protected static void ValidateDataType(string address, FocasDataType type)
{
if (FocasTagDefinitionFactory.DescribeDataTypeMismatch(type, address) is { } error)
throw new CliFx.Exceptions.CommandException(error);
}
}
@@ -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; }
}
/// <summary>
/// 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. <see cref="FocasDataType.String"/> 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 <c>ALARM:</c> tag has no value, so its
/// data type is irrelevant; an unparseable address is left to the address check, which names it better.
/// </summary>
/// <param name="dataType">The authored data type.</param>
/// <param name="address">The authored address.</param>
/// <returns>The reason the pair cannot be served, or <see langword="null"/> when it can.</returns>
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.";
}
/// <summary>
/// The tag's cross-driver <c>alarm</c> object, or <see langword="null"/> when it carries none.
/// Lenient like the host's <c>TagConfigIntent</c>: a non-integer <c>severity</c> is simply absent.
@@ -236,6 +259,10 @@ public static class FocasTagDefinitionFactory
}
if (TagConfigJson.DescribeInvalidEnum<FocasDataType>(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.");
}
/// <summary>
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,9 @@ public sealed class SynchronizedFocasClient : IFocasClient
private readonly IFocasClient _inner;
private readonly TimeSpan _callTimeout;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>Ops holding or queued for <see cref="_gate"/> — what <see cref="DisposeWhenIdleAsync"/> drains.</summary>
private int _pending;
private int _disposed;
/// <summary>Wraps <paramref name="inner"/> with per-device serialization + a per-call timeout.</summary>
/// <param name="inner">The underlying FOCAS client to serialize access to.</param>
@@ -115,26 +118,66 @@ public sealed class SynchronizedFocasClient : IFocasClient
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleMotorSpeedsAsync(ct), cancellationToken);
/// <summary>Disposes the inner client and the call gate.</summary>
/// <summary>
/// Disposes the inner client. Ops still queued on the call gate then fail with
/// <see cref="ObjectDisposedException"/> 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
/// <see cref="SemaphoreSlim"/> threw out of a read that had otherwise completed.
/// </summary>
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_inner.Dispose();
_gate.Dispose();
}
/// <summary>
/// Disposes once every op that already holds (or is queued for) this client has finished, waiting at most
/// <paramref name="maxWait"/>. 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).
/// </summary>
/// <param name="maxWait">Upper bound on the wait; the client is disposed regardless once it passes.</param>
/// <returns>A task that completes when the client is disposed.</returns>
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();
}
/// <summary>Gets the number of ops currently holding or queued for the call gate. Test seam.</summary>
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<T> RunGatedAsync<T>(Func<CancellationToken, Task<T>> 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<CancellationToken, Task> 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<T> RunBoundedAsync<T>(Func<CancellationToken, Task<T>> 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); }
}
}
@@ -467,18 +467,41 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The raw parameter payload bytes, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<byte[]>> 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);
}
/// <summary>
/// The <c>cnc_rdparam</c> (0x000e) request block. The data number travels in the 32-bit
/// <c>Arg1</c> UNNARROWED — the request block has always carried <see cref="int"/> arguments, and
/// a <c>short</c> cast in the adapter once asked the CNC for a different (wrapped) number than the
/// one authored. A global parameter repeats the number in <c>Arg2</c>; an axis parameter carries
/// the axis there.
/// </summary>
/// <param name="dataNumber">The parameter data number.</param>
/// <param name="axis">Axis selector; zero means a global parameter.</param>
/// <param name="pathId">The CNC path.</param>
/// <returns>The request block.</returns>
internal static RequestBlock ParameterRequest(int dataNumber, short axis, ushort pathId) =>
new(0x000e, dataNumber, axis == 0 ? dataNumber : axis, PathId: pathId);
/// <summary>
/// The <c>cnc_rdmacro</c> (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.
/// </summary>
/// <param name="number">The macro variable number.</param>
/// <param name="pathId">The CNC path.</param>
/// <returns>The request block.</returns>
internal static RequestBlock MacroRequest(int number, ushort pathId) =>
new(0x0015, number, number, PathId: pathId);
/// <summary>Typed Int32 parameter read — convenience over <see cref="ReadParameterBytesAsync"/>.</summary>
/// <param name="dataNumber">The parameter data number.</param>
/// <param name="type">The FOCAS parameter type code.</param>
@@ -487,7 +510,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<WireParameter>> ReadParameterAsync(
short dataNumber,
int dataNumber,
short type = 0,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
@@ -507,7 +530,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed 8-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<byte>> ReadParameterByteAsync(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<byte>> 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
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed 16-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<short>> ReadParameterInt16Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<short>> 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
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed 32-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<int>> ReadParameterInt32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<int>> 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
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed single-precision parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<float>> ReadParameterFloat32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<float>> 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
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed double-precision parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<double>> ReadParameterFloat64Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<double>> 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
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The macro variable value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public Task<FocasResult<WireMacro>> 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);
}
/// <summary>
/// Decode a <c>cnc_rdmacro</c> (0x0015) payload. The on-wire <c>ODBM</c> record is 8
@@ -609,7 +635,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="payload">The response-block payload.</param>
/// <returns>The decoded macro.</returns>
/// <exception cref="FocasWireException">The payload is shorter than 8 bytes.</exception>
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
/// <param name="count">The requested record count.</param>
/// <returns>The decoded alarms.</returns>
/// <exception cref="FocasWireException">The payload length matches neither record layout.</exception>
/// <remarks>
/// <para><b>Choosing the layout.</b> 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 <c>Length % 76 == 0</c> 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 <paramref name="count"/> is ruled out; (2) each layout's own field
/// invariants, checked on every record (<see cref="LegacyRecordsValid"/> / <see cref="VendorRecordsValid"/>).
/// Only when both readings remain plausible does the vendor layout win, as before.</para>
/// </remarks>
internal static IReadOnlyList<WireAlarm> 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;
/// <summary>Every 80-byte record's int32 type / axis / length fit a short, and the length fits the message.</summary>
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;
}
/// <summary>Every 76-byte record's i16 message length fits the 64-byte message.</summary>
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;
}
/// <summary>Every record's declared message length equals the length of the text actually in its message field.</summary>
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<WireAlarm> ParseVendorAlarms(byte[] payload, short count)
{
var alarms = new List<WireAlarm>();
@@ -170,7 +170,7 @@ public readonly record struct WireSpindleRecord(short Index, string Name);
/// <summary>Parameter value returned by <c>cnc_rdparam</c>, interpreted as a scalar Int32.</summary>
public sealed record WireParameter(
short DataNumber,
int DataNumber,
short Type,
int Value);
@@ -184,7 +184,7 @@ public sealed record WireParameter(
/// <param name="Value">The 9-significant-digit mantissa.</param>
/// <param name="Decimal">Implied decimal places; negative (<c>0xFFFF</c> on the wire) = vacant.</param>
public sealed record WireMacro(
short Number,
int Number,
int Value,
short Decimal)
{
@@ -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 ------------------------------------------
/// <summary>
/// The 16-bit byte range <c>pmc_rdpmcrng</c> addresses for a slot of <paramref name="width"/> bytes at
/// <paramref name="number"/>. 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).
/// </summary>
/// <param name="number">The authored PMC byte address.</param>
/// <param name="width">The slot width in bytes (≥ 1).</param>
/// <param name="start">The first byte, when representable.</param>
/// <param name="end">The last byte, when representable.</param>
/// <returns>True when the whole slot is addressable.</returns>
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);
@@ -15,8 +15,22 @@
</div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, FocasDataType.Int32)))">
@foreach (var v in Enum.GetValues<FocasDataType>()) { <option value="@v">@v</option> }
</select></div>
@foreach (var v in Enum.GetValues<FocasDataType>())
{
@* 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)
{
<option value="@v">@v</option>
}
}
</select>
@if (FocasTagDefinitionFactory.DescribeDataTypeMismatch(_m.DataType, _m.Address) is { } typeError)
{
<div class="form-text text-danger small">@typeError</div>
}
</div>
<div class="col-12">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="focas-writable" disabled checked="false" />
@@ -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
@@ -33,6 +33,27 @@ public sealed class FocasCommandBaseValidationTests
/// <summary>Invokes option validation without interval.</summary>
public void InvokeValidateNoInterval() => ValidateOptions(intervalMs: null);
/// <summary>Invokes the address/data-type check.</summary>
public void InvokeValidateDataType(string address, FocasDataType type) => ValidateDataType(address, type);
}
/// <summary>
/// #740 / R07:U8 — <c>--type String</c> 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.
/// </summary>
[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<CliFx.Exceptions.CommandException>(() => sut.InvokeValidateDataType(address, type))
.Message.ShouldContain("String");
}
/// <summary>Verifies that default options are accepted.</summary>
@@ -72,6 +72,155 @@ public sealed class WireBackendCoverageTests
}
}
/// <summary>
/// #740 / R07:H14 — a macro / parameter number above 32767 reaches the CNC unnarrowed. The mock decodes
/// the request's 32-bit <c>Arg1</c> as the number, so a decoy is seeded at the address the old
/// <c>(short)</c> 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.
/// </summary>
[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<string, object>
{
["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<string, object>
{
["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)");
}
}
/// <summary>
/// #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).
/// </summary>
[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<string, object> { ["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<Exception>();
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);
}
}
/// <summary>Verifies that discover emits device folder and tag variables.</summary>
[Fact]
public async Task Discover_emits_device_folder_and_tag_variables()
@@ -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;
/// <summary>
/// #740 fix round 1 (review I2) — wire-level cases for H15 (fixed-tree health) and H20 (alarm record layout)
/// against the focas-mock fixture.
/// </summary>
[Collection(FocasSimCollection.Name)]
public sealed class WireBackendHealthAndLayoutTests
{
private readonly FocasSimFixture _fx;
/// <summary>Initializes a new instance of the test class with the FOCAS simulation fixture.</summary>
/// <param name="fx">The FOCAS simulation fixture.</param>
public WireBackendHealthAndLayoutTests(FocasSimFixture fx) => _fx = fx;
/// <summary>
/// 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: <see cref="Down"/> closes every relayed session and stops
/// listening (new connects are refused); <see cref="Up"/> listens again on the same port.
/// </summary>
private sealed class CncRelay : IAsyncDisposable
{
private readonly string _targetHost;
private readonly int _targetPort;
private readonly List<TcpClient> _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();
}
}
/// <summary>
/// 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 <see cref="Down"/>, or hand it to another process — the latter
/// surfaces as a clear failure after the retries rather than a random flake.
/// </summary>
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;
}
}
/// <summary>
/// H15 over the wire: a driver serving only <c>FIXED:</c> 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).
/// </summary>
[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);
}
}
/// <summary>
/// H20 over the wire: the mock answers <c>cnc_rdalmmsg2</c> 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.
/// </summary>
[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<object>() }, ct);
}
static object[] Alarms(int n) => [.. Enumerable.Range(0, n)
.Select(i => (object)new { alm_no = 400 + i, type = 2 + (i % 3), axis = 1 + (i % 4), msg = $"SERVO ALARM {400 + i}" })];
}
private static async Task WaitFor(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
await Task.Delay(25);
}
}
}
@@ -259,7 +259,7 @@ internal sealed class FakeFocasClientFactory : IFocasClientFactory
public IFocasClient Create()
{
var c = Customise?.Invoke() ?? new FakeFocasClient();
Clients.Add(c);
lock (Clients) Clients.Add(c);
return c;
}
}
@@ -0,0 +1,114 @@
using System.Buffers.Binary;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H20 — <c>ParseAlarms</c> picked the 76-byte vendor layout whenever <c>Length % 76 == 0</c>, so a
/// legacy 80-byte reply of exactly 19 records (1,520 B = 20 × 76) decoded as 20 garbage vendor records. The
/// response block carries no record count, so the layout is now chosen from the two things that are known: the
/// count the REQUEST asked for (a reply never holds more) and each layout's own field invariants.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasAlarmLayoutTests
{
private static byte[] Legacy(params (int no, int type, int axis, string msg)[] records)
{
var buf = new byte[records.Length * 80];
for (var i = 0; i < records.Length; i++)
{
var o = i * 80;
var (no, type, axis, msg) = records[i];
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no);
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 4, 4), type);
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 8, 4), axis);
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 12, 4), msg.Length);
Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 16));
}
return buf;
}
private static byte[] Vendor(params (int no, short type, short axis, string msg)[] records)
{
var buf = new byte[records.Length * 76];
for (var i = 0; i < records.Length; i++)
{
var o = i * 76;
var (no, type, axis, msg) = records[i];
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no);
BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 4, 2), type);
BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 6, 2), axis);
BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 10, 2), (short)msg.Length);
Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 12));
}
return buf;
}
private static (int, int, int, string)[] LegacySet(int n) =>
[.. Enumerable.Range(0, n).Select(i => (400 + i, 2 + (i % 3), 1 + (i % 4), $"SERVO ALARM {400 + i}"))];
private static (int, short, short, string)[] VendorSet(int n) =>
[.. Enumerable.Range(0, n).Select(i => (1000 + i, (short)(4 + (i % 3)), (short)(i % 2), $"OVERTRAVEL +{i}"))];
/// <summary>The ambiguous length: 19 legacy records decode as 19 legacy records, not 20 vendor ones.</summary>
[Fact]
public void Nineteen_legacy_records_decode_as_legacy()
{
var payload = Legacy(LegacySet(19));
payload.Length.ShouldBe(1520);
(payload.Length % 76).ShouldBe(0, "the length really is ambiguous");
var alarms = FocasWireClient.ParseAlarms(payload, 32);
alarms.Count.ShouldBe(19);
alarms[0].AlarmNumber.ShouldBe(400);
alarms[0].Message.ShouldBe("SERVO ALARM 400");
alarms[18].AlarmNumber.ShouldBe(418);
alarms[18].Axis.ShouldBe((short)(1 + (18 % 4)));
}
/// <summary>Positive control at the same length: 20 vendor records still decode as vendor.</summary>
[Fact]
public void Twenty_vendor_records_decode_as_vendor()
{
var payload = Vendor(VendorSet(20));
payload.Length.ShouldBe(1520);
var alarms = FocasWireClient.ParseAlarms(payload, 32);
alarms.Count.ShouldBe(20);
alarms[0].AlarmNumber.ShouldBe(1000);
alarms[19].Message.ShouldBe("OVERTRAVEL +19");
}
/// <summary>
/// Review I2 edge: vendor records with type 0 and EMPTY messages also satisfy the legacy invariants (the
/// int32 read of <c>type&lt;&lt;16 | axis</c> is just the axis, and the "length" int32 is the empty message's
/// zero bytes) — both readings are plausible, so the vendor default must hold and the numbers decode.
/// </summary>
[Fact]
public void Twenty_vendor_records_with_type_zero_and_empty_messages_stay_vendor()
{
var payload = Vendor([.. Enumerable.Range(0, 20).Select(i => (2000 + i, (short)0, (short)(i % 3), ""))]);
payload.Length.ShouldBe(1520);
var alarms = FocasWireClient.ParseAlarms(payload, 32);
alarms.Count.ShouldBe(20);
alarms.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(2000, 20));
alarms.ShouldAllBe(a => a.Type == 0 && a.Message == "");
}
/// <summary>The unambiguous lengths are unchanged.</summary>
[Fact]
public void Unambiguous_lengths_are_unchanged()
{
FocasWireClient.ParseAlarms(Vendor(VendorSet(3)), 32).Count.ShouldBe(3);
FocasWireClient.ParseAlarms(Legacy(LegacySet(3)), 32).Count.ShouldBe(3);
FocasWireClient.ParseAlarms([], 32).ShouldBeEmpty();
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[77], 32));
}
}
@@ -645,7 +645,8 @@ public sealed class FocasAlarmProjectionTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// Value path Degraded, alarm path good ⇒ Degraded: an alarm success cannot mask it.
factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
// A COMMS-class status: a CNC refusal (e.g. BadOutOfRange) is not a value-path Degraded since #740.
factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadCommunicationError;
await drv.ReadAsync([valuePath], CancellationToken.None);
drv.NoteAlarmReadSucceeded(Host);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
@@ -0,0 +1,284 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:P9 — <see cref="FocasDriver.ReadAsync"/> walked the whole batch sequentially, so a SLOW CNC
/// (TCP-accept-then-stall, the live 31i-B case) cost its timeout per tag per poll during which no other
/// device was read. Devices are now read concurrently, each still serialised on its own socket.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasConcurrentDeviceReadTests
{
private const string HostA = "focas://10.0.0.5:8193";
private const string HostB = "focas://10.0.0.6:8193";
/// <summary>
/// A client that records which device it dialled. Device A's read only completes once device B's read has
/// STARTED — so a sequential walk (A first) can never finish A, while a concurrent one does. Also counts
/// the peak number of reads in flight on any one client.
/// </summary>
private sealed class OrderingClient(TaskCompletionSource bStarted, int[] global) : FakeFocasClient
{
private string _host = "";
private int _inFlight;
public int PeakInFlight;
public int Reads;
public override Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
_host = address.ToString();
return base.ConnectAsync(address, timeout, ct);
}
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
var now = Interlocked.Increment(ref _inFlight);
InterlockedMax(ref PeakInFlight, now);
Interlocked.Increment(ref Reads);
// global[0] = reads in flight across EVERY client, global[1] = its peak.
InterlockedMax(ref global[1], Interlocked.Increment(ref global[0]));
try
{
if (_host.Contains("10.0.0.6", StringComparison.Ordinal)) bStarted.TrySetResult();
else await bStarted.Task.WaitAsync(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false);
await Task.Delay(20, ct).ConfigureAwait(false);
return ((sbyte)1, FocasStatusMapper.Good);
}
finally
{
Interlocked.Decrement(ref _inFlight);
Interlocked.Decrement(ref global[0]);
}
}
private static void InterlockedMax(ref int target, int value)
{
int seen;
while ((seen = Volatile.Read(ref target)) < value
&& Interlocked.CompareExchange(ref target, value, seen) != seen) { }
}
}
/// <summary>A stalled device no longer holds every other device's read hostage — and each device stays serialised.</summary>
[Fact]
public async Task Devices_are_read_concurrently_and_each_stays_serialised()
{
var bStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var global = new int[2];
var factory = new FakeFocasClientFactory { Customise = () => new OrderingClient(bStarted, global) };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
RawTags =
[
FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)),
FocasRawTags.Entry(new FocasTagDefinition("raw/A2", "A", "R101", FocasDataType.Byte)),
FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R100", FocasDataType.Byte)),
],
Probe = new FocasProbeOptions { Enabled = false },
// Off so every tag takes the per-tag path — this pins device-level concurrency, not coalescing.
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
}, "focas-p9", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/A1", "raw/A2", "raw/B1"], CancellationToken.None);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good,
"device A's read can only finish once device B's has started — a sequential walk times A out");
factory.Clients.Count.ShouldBe(2);
var clients = factory.Clients.Cast<OrderingClient>().ToList();
// Every client really served reads (2 on A, 1 on B), so "peak 1" is measured, not vacuous …
clients.Select(c => c.Reads).OrderBy(n => n).ShouldBe([1, 2]);
clients.ShouldAllBe(c => c.PeakInFlight == 1,
"one device's socket is still request→response: never two reads in flight on it");
// … and the two devices overlapped: two reads were in flight at once across the clients.
global[1].ShouldBe(2, "the devices must be read at the same time, not one after the other");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A device that accepted TCP and then stopped answering: every read hangs until the call timeout.</summary>
private sealed class StalledClient : FakeFocasClient
{
public int Reads;
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
Interlocked.Increment(ref Reads);
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
return (null, FocasStatusMapper.Good);
}
}
/// <summary>
/// #740 review I1 — <c>Task.WhenAll</c> waits for the slowest device, so concurrency alone still made a
/// stalled device cost (its tag count × the call timeout) per batch, and the healthy device's values waited
/// that long. After the first timeout on a device its remaining tags in the batch fail fast: the batch is
/// bounded by about ONE call timeout and the healthy device's values are Good.
/// </summary>
[Fact]
public async Task A_stalled_device_costs_one_call_timeout_per_batch_not_one_per_tag()
{
var callTimeout = TimeSpan.FromMilliseconds(200);
var factory = new FakeFocasClientFactory();
factory.Customise = () => factory.Clients.Count == 0 ? new StalledClient() : new FakeFocasClient { Values = { ["R100"] = (sbyte)9 } };
var stalledTags = Enumerable.Range(0, 10)
.Select(i => FocasRawTags.Entry(new FocasTagDefinition($"raw/A{i}", "A", $"R{100 + i}", FocasDataType.Byte)));
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
RawTags = [.. stalledTags, FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
Timeout = callTimeout,
}, "focas-stall", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Connect device A first so it gets the stalled client (connect order = client order).
await drv.ReadAsync(["raw/A0"], CancellationToken.None);
var refs = Enumerable.Range(0, 10).Select(i => $"raw/A{i}").Append("raw/B1").ToArray();
var sw = System.Diagnostics.Stopwatch.StartNew();
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
sw.Stop();
sw.Elapsed.ShouldBeLessThan(callTimeout * 3, "one stalled device must cost ~one call timeout, not ten");
snaps[10].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[10].Value.ShouldBe((sbyte)9);
snaps.Take(10).ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
((StalledClient)factory.Clients[0]).Reads.ShouldBe(2, "one read in the warm-up batch, one in the measured batch");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Counts per-tag reads; one address can be made to time out (throw) or answer a status.</summary>
private sealed class CountingClient : FakeFocasClient
{
public int Reads;
public string? TimeoutAt;
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
Interlocked.Increment(ref Reads);
if (address.Canonical == TimeoutAt)
{
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
}
return await base.ReadAsync(address, type, ct).ConfigureAwait(false);
}
}
private static FocasDriver OneDevice(FakeFocasClientFactory factory, bool coalesce, params string[] addresses) => new(
new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A")],
RawTags = [.. addresses.Select(a => FocasRawTags.Entry(new FocasTagDefinition($"raw/{a}", "A", a, FocasDataType.Byte)))],
Probe = new FocasProbeOptions { Enabled = false },
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = coalesce },
Timeout = TimeSpan.FromMilliseconds(200),
}, "focas-linkdown", factory);
/// <summary>
/// #740 review round 2 N1 — a status the CNC RETURNED (EW_REJECT and every unlisted rc map to
/// BadCommunicationError) proves the device answered, so it must not end the device's batch: the tags after it
/// still read. Before the fix, a deterministic rc on the first tag starved every later tag forever.
/// </summary>
[Fact]
public async Task A_returned_comms_status_does_not_fail_the_rest_of_the_batch()
{
var client = new CountingClient { Values = { ["R101"] = (sbyte)1, ["R102"] = (sbyte)2 } };
client.ReadStatuses["R100"] = FocasStatusMapper.MapFocasReturn(13); // EW_REJECT ⇒ BadCommunicationError
var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: false, "R100", "R101", "R102");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R102"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[2].Value.ShouldBe((sbyte)2);
client.Reads.ShouldBe(3, "every tag was really read");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Positive control for N1: a TIMEOUT (no reply at all) still fails the rest of the batch without reading it.</summary>
[Fact]
public async Task A_timeout_still_fails_the_rest_of_the_batch_fast()
{
var client = new CountingClient { TimeoutAt = "R100", Values = { ["R101"] = (sbyte)1 } };
var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: false, "R100", "R101", "R102");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R102"], CancellationToken.None);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
client.Reads.ShouldBe(1, "only the tag that timed out touched the wire");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Review round 2 m5 — the coalescer's link-down paths: a transport fault on the first merged range fails the
/// second range WITHOUT a second range read, and the per-tag tags of the same device without a per-tag read.
/// </summary>
[Fact]
public async Task Coalescer_transport_fault_fails_later_ranges_and_per_tag_tags_without_reading_them()
{
var client = new CountingClient { ThrowOnPmcRange = true };
// Two ranges far apart (gap > MaxGapBytes) + one lone tag that takes the per-tag path.
var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: true,
"R100", "R101", "R500", "R501", "D900");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R500", "raw/R501", "raw/D900"], CancellationToken.None);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
client.PmcRangeReads.Count.ShouldBe(1, "the second range must not pay another transport attempt");
client.Reads.ShouldBe(0, "the per-tag tag must not pay one either");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Review round 2 m5 — a coalescer CONNECT failure fails the per-tag tags of the device without a second connect.</summary>
[Fact]
public async Task Coalescer_connect_failure_fails_per_tag_tags_without_reconnecting()
{
var factory = new FakeFocasClientFactory { Customise = () => new CountingClient { ThrowOnConnect = true } };
var drv = OneDevice(factory, coalesce: true, "R100", "R101", "D900");
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/D900"], CancellationToken.None);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
factory.Clients.Count.ShouldBe(1, "one connect attempt per device per batch");
factory.Clients.Sum(c => c.ConnectCount).ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Results land at their own indices whatever order the devices finish in.</summary>
[Fact]
public async Task Results_keep_their_request_order_across_devices()
{
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
RawTags =
[
FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)),
FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R200", FocasDataType.Byte)),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-p9-order", factory)
;
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1, ["R200"] = (sbyte)2 } };
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/B1", "nope", "raw/A1"], CancellationToken.None);
snaps[0].Value.ShouldBe((sbyte)2);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
snaps[2].Value.ShouldBe((sbyte)1);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,200 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H13 — the device table is read by the retained poll engine, the fixed-tree loop, the alarm
/// loop and the actor's health tick, so a re-initialise must build a fresh table and swap it in rather than
/// clearing and refilling the live one. Also pins the lifecycle overlaps the runtime produces since Lane A:
/// a dispose running beside a still-running shutdown, and an abandoned initialise overlapping the next
/// attempt's shutdown.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasDeviceTableLifecycleTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriverOptions Options(bool probe = false, bool fixedTree = false) => new()
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R", Host, "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = probe, Interval = TimeSpan.FromMilliseconds(20) },
FixedTree = new FocasFixedTreeOptions { Enabled = fixedTree, PollInterval = TimeSpan.FromMilliseconds(10) },
};
/// <summary>A re-initialise publishes a NEW table; a reader holding the old one never sees it emptied.</summary>
[Fact]
public async Task Reinitialize_swaps_in_a_new_table_and_never_mutates_the_one_a_reader_holds()
{
var drv = new FocasDriver(Options(), "focas-swap", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var before = drv.DeviceTable;
before.Count.ShouldBe(1);
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceTable.ShouldNotBeSameAs(before);
drv.DeviceTable.Count.ShouldBe(1);
before.Count.ShouldBe(1, "the table a concurrent reader enumerates must not be cleared under it");
await drv.ShutdownAsync(CancellationToken.None);
before.Count.ShouldBe(1);
drv.DeviceTable.Count.ShouldBe(0);
}
/// <summary>Hammering every table reader during repeated re-initialises throws nothing — and reads still succeed.</summary>
[Fact]
public async Task Readers_survive_repeated_reinitialise()
{
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(Options(fixedTree: true), "focas-hammer", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
using var stop = new CancellationTokenSource();
var good = 0;
var errors = new List<Exception>();
var reader = Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
try
{
_ = drv.GetHostStatuses();
var snaps = await drv.ReadAsync(["raw/R", $"{Host}/Identity/SeriesNumber"], CancellationToken.None);
if (snaps[0].StatusCode == FocasStatusMapper.Good) Interlocked.Increment(ref good);
}
catch (Exception ex) { lock (errors) errors.Add(ex); }
}
});
for (var i = 0; i < 100; i++)
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(100);
stop.Cancel();
await reader;
await drv.ShutdownAsync(CancellationToken.None);
errors.ShouldBeEmpty();
good.ShouldBeGreaterThan(0, "positive control: the reader really did read through the table");
}
/// <summary>A client whose connect parks until released and ignores cancellation.</summary>
private sealed class ParkedConnectClient(TaskCompletionSource entered, Task release) : FakeFocasClient
{
public override async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
entered.TrySetResult();
await release.ConfigureAwait(false);
await base.ConnectAsync(address, timeout, CancellationToken.None).ConfigureAwait(false);
}
}
/// <summary>
/// A data-path read parked in connect when shutdown runs must not leave a live client on the released
/// device: the device is retired, so the connect that completes afterwards disposes what it opened.
/// </summary>
[Fact]
public async Task Connect_completing_after_shutdown_does_not_leave_a_live_client()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeFocasClientFactory { Customise = () => new ParkedConnectClient(entered, release.Task) };
var drv = new FocasDriver(Options(), "focas-retire", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var read = drv.ReadAsync(["raw/R"], CancellationToken.None);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var shutdown = drv.ShutdownAsync(CancellationToken.None);
await Task.Delay(50);
release.SetResult();
await shutdown.WaitAsync(TimeSpan.FromSeconds(10));
var snaps = await read.WaitAsync(TimeSpan.FromSeconds(5));
factory.Clients.Count.ShouldBe(1);
factory.Clients[0].IsConnected.ShouldBeFalse("a connect finishing on a retired device must dispose its client");
factory.Clients[0].DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
/// <summary>
/// An initialise abandoned mid-way (the runtime timed it out and moved on to a shutdown) must not
/// resurrect the driver when it finally completes: no device table, no loops, no connection.
/// </summary>
[Fact]
public async Task Abandoned_initialise_finishing_after_shutdown_starts_nothing()
{
var factory = new FakeFocasClientFactory();
var options = Options(probe: true);
using var gate = new ManualResetEventSlim(false);
var inRebind = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var drv = new FocasDriver(options, "focas-abandoned", factory, rebind: _ =>
{
inRebind.TrySetResult();
gate.Wait(TimeSpan.FromSeconds(10));
return new FocasDriverBinding(options, factory);
});
var init = Task.Run(() => drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None));
await inRebind.Task.WaitAsync(TimeSpan.FromSeconds(5));
await drv.ShutdownAsync(CancellationToken.None);
gate.Set();
await Should.ThrowAsync<OperationCanceledException>(() => init.WaitAsync(TimeSpan.FromSeconds(10)));
await Task.Delay(200); // several probe intervals — a started loop would have connected by now
drv.DeviceCount.ShouldBe(0);
factory.Clients.ShouldBeEmpty();
drv.GetHealth().State.ShouldNotBe(DriverState.Healthy);
// Positive control: the same driver, initialised without being superseded, does start its probe.
await drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None);
await WaitUntil(() => factory.Clients.Count > 0);
drv.DeviceCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.DisposeAsync();
}
/// <summary>A loop wedged in a read that ignores cancellation for a while.</summary>
private sealed class SlowProbeClient : FakeFocasClient
{
public override async Task<bool> ProbeAsync(CancellationToken ct)
{
await Task.Delay(400, CancellationToken.None).ConfigureAwait(false);
return true;
}
}
/// <summary>
/// Lane A disposes while its own shutdown may still be running. The dispose must not return before the
/// teardown it overlaps has released the client — it waits for it rather than finding an empty table.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_shutdown_waits_for_the_teardown()
{
var factory = new FakeFocasClientFactory { Customise = () => new SlowProbeClient() };
var drv = new FocasDriver(Options(probe: true), "focas-dispose", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntil(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
var shutdown = drv.ShutdownAsync(CancellationToken.None);
var dispose = drv.DisposeAsync().AsTask();
await dispose.WaitAsync(TimeSpan.FromSeconds(10));
factory.Clients.ShouldAllBe(c => !c.IsConnected);
factory.Clients.ShouldAllBe(c => c.DisposeCount >= 1);
await shutdown.WaitAsync(TimeSpan.FromSeconds(10));
drv.DeviceCount.ShouldBe(0);
}
private static async Task WaitUntil(Func<bool> condition)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
await Task.Delay(10);
}
}
}
@@ -0,0 +1,163 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H15 — the fixed-tree loop swallowed every failure at Debug and never touched driver health, so a
/// driver serving only <c>FIXED:</c> tags (Identity / Axes / Program / Status — the typical FOCAS shape) read
/// Healthy on <c>/hosts</c> with every node Bad, and the #527 comms-loss watchdog could never fire. The loop
/// now credits each answered axis pass (Healthy + LastSuccessfulRead) and, once a device has been silent past
/// the fixed-tree staleness budget, degrades the driver with LastSuccessfulRead frozen.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasFixedTreeHealthTests
{
private const string Host = "focas://10.0.0.5:8193";
private static readonly TimeSpan Stale = TimeSpan.FromMilliseconds(300);
private static readonly TimeSpan BlipBudget = TimeSpan.FromMilliseconds(1500);
/// <summary>A CNC that can be switched off (transport failure) or made to refuse the dynamic read.</summary>
private sealed class CncSwitch
{
public volatile bool Down;
public volatile bool Refuse;
/// <summary>Transport failures served so far — what the tests wait on instead of wall-clock delays.</summary>
public int Failures;
/// <summary>Refusals served so far.</summary>
public int Refusals;
}
private sealed class SwitchableCnc(CncSwitch sw) : FakeFocasClient
{
private bool Down => sw.Down;
private bool Refuse => sw.Refuse;
public override Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct)
{
if (!Down) return base.GetSysInfoAsync(ct);
Interlocked.Increment(ref sw.Failures);
throw new IOException("socket closed");
}
public override Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken ct)
{
if (Down)
{
Interlocked.Increment(ref sw.Failures);
throw new IOException("socket closed");
}
if (Refuse)
{
Interlocked.Increment(ref sw.Refusals);
throw new FocasWireException("cnc_rddynamic2 refused", rc: 6);
}
return base.ReadDynamicAsync(axisIndex, ct);
}
}
private static FocasDriver FixedOnlyDriver(CncSwitch sw, TimeSpan? budget = null) => new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/X", Host, "FIXED:Axes/X/AbsolutePosition", FocasDataType.Float64))],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
PollInterval = TimeSpan.FromMilliseconds(20),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, "focas-fixed-health", new FakeFocasClientFactory { Customise = () => new SwitchableCnc(sw) })
{
FixedTreeStaleBudgetOverride = budget ?? Stale,
};
/// <summary>
/// Positive control first, then the outage: an answering CNC keeps the driver Healthy with a moving
/// LastSuccessfulRead; a CNC that stops answering degrades it once the budget passes, with
/// LastSuccessfulRead frozen (the watchdog's conjunction); and its return restores Healthy.
/// </summary>
[Fact]
public async Task Fixed_only_driver_degrades_when_its_cnc_disappears_and_recovers_when_it_returns()
{
var sw = new CncSwitch();
// A wide budget (review round 2 m4): two 20 ms ticks always land well inside it, so the "blip does not
// degrade" absence check below runs unconditionally instead of being skipped on a slow agent.
await using var drv = FixedOnlyDriver(sw, BlipBudget);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntil(() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null);
var first = drv.GetHealth().LastSuccessfulRead!.Value;
// An answered axis pass is a successful read: wait for it to move rather than sleeping.
await WaitUntil(() => drv.GetHealth().LastSuccessfulRead > first);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
sw.Down = true;
var lastAnswer = drv.GetDeviceState(Host)!.LastFixedPollUtc!.Value;
// Two failed ticks have demonstrably happened (positive evidence for the absence check below) …
await WaitUntil(() => Volatile.Read(ref sw.Failures) >= 2);
// … inside the budget (asserted, not assumed), and the driver is still Healthy.
(DateTime.UtcNow - lastAnswer).ShouldBeLessThan(BlipBudget, "the two failed ticks must land inside the budget");
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a blip inside the staleness budget does not degrade");
await WaitUntil(() => drv.GetHealth().State == DriverState.Degraded);
var frozen = drv.GetHealth().LastSuccessfulRead;
var failuresAtDegrade = Volatile.Read(ref sw.Failures);
await WaitUntil(() => Volatile.Read(ref sw.Failures) >= failuresAtDegrade + 5);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen, "LastSuccessfulRead must stay frozen while the CNC is silent");
sw.Down = false;
await WaitUntil(() => drv.GetHealth().State == DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(frozen!.Value);
}
/// <summary>A CNC that is down from the start (bootstrap never succeeds) degrades once the budget passes.</summary>
[Fact]
public async Task Bootstrap_that_never_succeeds_degrades_the_driver()
{
var sw = new CncSwitch { Down = true };
await using var drv = FixedOnlyDriver(sw);
await drv.InitializeAsync("{}", CancellationToken.None);
// The bootstrap retries every 2 s, so the first failure is what has to trip it.
await WaitUntil(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(6));
sw.Down = false;
}
/// <summary>
/// A CNC that ANSWERS with a refusal is not a comms loss: the transport is fine, so the driver is not
/// degraded (else the watchdog would reconnect-loop a production control).
/// </summary>
[Fact]
public async Task Cnc_refusal_of_the_dynamic_read_does_not_degrade()
{
var sw = new CncSwitch();
await using var drv = FixedOnlyDriver(sw);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntil(() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null);
sw.Refuse = true;
var started = DateTime.UtcNow;
await WaitUntil(() => Volatile.Read(ref sw.Refusals) >= 1);
var atFirstRefusal = drv.GetHealth().LastSuccessfulRead!.Value;
// Refused ticks really happened, for longer than the staleness budget …
await WaitUntil(() => Volatile.Read(ref sw.Refusals) >= 5 && DateTime.UtcNow - started > Stale * 2);
// … and the driver stayed Healthy, with each refusal crediting the answer (review minor 2).
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(atFirstRefusal,
"a refusal is an answer from the CNC — LastSuccessfulRead advances");
sw.Refuse = false;
}
private static async Task WaitUntil(Func<bool> condition, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5));
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
await Task.Delay(10);
}
}
}
@@ -62,6 +62,73 @@ public sealed class FocasHandleRecycleTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A client whose reads take a while — long enough for a recycle tick to land mid-read.</summary>
private sealed class SlowReadClient : FakeFocasClient
{
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
await Task.Delay(200, ct).ConfigureAwait(false);
if (!IsConnected) throw new ObjectDisposedException(nameof(SlowReadClient), "disposed mid-read");
return ((sbyte)5, FocasStatusMapper.Good);
}
}
/// <summary>
/// #740 / R07:L6 — the recycle loop disposed the device client with no gate, so a recycle tick landing
/// mid-read killed that read (BadCommunicationError + Degraded, every interval). The recycle now takes the
/// device's connect gate and lets the in-flight read finish before disposing the client it detached.
/// </summary>
[Fact]
public async Task Recycle_tick_landing_mid_read_lets_the_read_finish()
{
var factory = new FakeFocasClientFactory { Customise = () => new SlowReadClient() };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
HandleRecycle = new FocasHandleRecycleOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Each 200 ms read spans at least three recycle ticks.
for (var i = 0; i < 3; i++)
{
var snap = (await drv.ReadAsync(["R"], CancellationToken.None))[0];
snap.StatusCode.ShouldBe(FocasStatusMapper.Good, $"read {i} was killed by a recycle tick");
snap.Value.ShouldBe((sbyte)5);
}
drv.GetHealth().State.ShouldBe(Core.Abstractions.DriverState.Healthy);
// Positive control: the recycle really ran — the first client was disposed and a fresh one opened.
await WaitFor(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
factory.Clients[0].DisposeCount.ShouldBeGreaterThan(0);
factory.Clients.Count.ShouldBeGreaterThan(1);
await drv.ShutdownAsync(CancellationToken.None);
factory.Clients.ShouldAllBe(c => !c.IsConnected, "no client opened around a recycle may be orphaned");
}
/// <summary>DisposeWhenIdleAsync drains the in-flight op, then disposes; a later op is refused, not run on a dead socket.</summary>
[Fact]
public async Task DisposeWhenIdle_drains_the_in_flight_op_then_refuses_new_ones()
{
var inner = new SlowReadClient();
await inner.ConnectAsync(new FocasHostAddress("10.0.0.5", 8193), TimeSpan.FromSeconds(1), CancellationToken.None);
var sync = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(5));
var addr = FocasAddress.TryParse("R100")!;
var read = sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None);
await Task.Delay(20);
sync.PendingOperations.ShouldBe(1);
var dispose = sync.DisposeWhenIdleAsync(TimeSpan.FromSeconds(5));
(await read).status.ShouldBe(FocasStatusMapper.Good);
await dispose;
inner.DisposeCount.ShouldBe(1);
await Should.ThrowAsync<ObjectDisposedException>(() => sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None));
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -57,4 +57,32 @@ public sealed class FocasPollErrorHealthTests
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>
/// #740 review round 2 m3 — a poll failure carries the generation its batch STARTED under, so a batch that
/// began before a re-initialise cannot degrade the new generation. Positive control: a failure stamped with
/// the live generation (or unstamped) does degrade.
/// </summary>
[Fact]
public async Task PollReaderFailure_from_a_previous_generation_does_not_degrade_the_new_one()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var oldGeneration = drv.LifecycleGeneration;
await drv.InitializeAsync("{}", CancellationToken.None);
var stale = new InvalidOperationException("old batch boom");
stale.Data[FocasDriver.BatchGenerationKey] = oldGeneration;
drv.HandlePollError(stale);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
var live = new InvalidOperationException("live batch boom");
live.Data[FocasDriver.BatchGenerationKey] = drv.LifecycleGeneration;
drv.HandlePollError(live);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
}
@@ -0,0 +1,212 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 driver-lane health checklist (drivers.md, #527/#611): a deterministic CNC refusal — the control
/// answered with a non-OK return code, or with a value the tag's type cannot hold (vacant macro, fractional
/// macro on an integer tag) — is per-tag Bad with ONE Warning per tag and NO health write, and it still
/// proves the device answered (LastSuccessfulRead advances). Only a comms-class status degrades the driver.
/// Before this, every non-Good status latched Degraded every poll, so one vacant macro made a reachable CNC
/// look sick forever.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasReadHealthContractTests
{
private const string Host = "focas://10.0.0.5:8193";
private sealed class WarningCounter : ILogger<FocasDriver>
{
private int _warnings;
public int Warnings => Volatile.Read(ref _warnings);
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (logLevel == LogLevel.Warning && formatter(state, exception).Contains("refused", StringComparison.Ordinal))
Interlocked.Increment(ref _warnings);
}
}
private static (FocasDriver drv, FakeFocasClient client, WarningCounter log) NewDriver(uint r100Status)
{
var client = new FakeFocasClient { Values = { ["R101"] = (sbyte)1 } };
client.ReadStatuses["R100"] = r100Status;
var log = new WarningCounter();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags =
[
FocasRawTags.Entry(new FocasTagDefinition("raw/R100", Host, "R100", FocasDataType.Byte)),
FocasRawTags.Entry(new FocasTagDefinition("raw/R101", Host, "R101", FocasDataType.Byte)),
],
Probe = new FocasProbeOptions { Enabled = false },
PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
}, "focas-contract", new FakeFocasClientFactory { Customise = () => client }, log);
return (drv, client, log);
}
/// <summary>A refusal leaves health alone, warns once, and still credits the answer.</summary>
[Theory]
[InlineData(FocasStatusMapper.BadOutOfRange)] // EW_NUMBER
[InlineData(FocasStatusMapper.BadNotSupported)] // EW_FUNC / EW_NOOPT
[InlineData(FocasStatusMapper.BadNoData)] // vacant macro
[InlineData(FocasStatusMapper.BadTypeMismatch)] // fractional macro on an integer tag
[InlineData(FocasStatusMapper.BadDeviceFailure)] // EW_BUSY
public async Task Cnc_refusal_is_per_tag_bad_with_one_warning_and_no_health_write(uint refusal)
{
var (drv, _, log) = NewDriver(refusal);
await drv.InitializeAsync("{}", CancellationToken.None);
var atInit = drv.GetHealth().LastSuccessfulRead;
for (var i = 0; i < 3; i++)
{
await Task.Delay(5);
var snap = (await drv.ReadAsync(["raw/R100"], CancellationToken.None))[0];
snap.StatusCode.ShouldBe(refusal);
}
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Healthy, "a CNC refusal is not a comms fault");
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(atInit!.Value, "the CNC answered — that is evidence of life");
log.Warnings.ShouldBe(1, "one Warning per refused tag, not one per poll");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A refused tag that recovers and is refused again warns again (the dedup clears on Good).</summary>
[Fact]
public async Task Refusal_warning_rearms_after_the_tag_reads_good()
{
var (drv, client, log) = NewDriver(FocasStatusMapper.BadOutOfRange);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
client.ReadStatuses.Remove("R100");
client.Values["R100"] = (sbyte)3;
(await drv.ReadAsync(["raw/R100"], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
client.ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
log.Warnings.ShouldBe(2);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Positive control: a comms-class status still degrades, preserving LastSuccessfulRead.</summary>
[Fact]
public async Task Comms_status_still_degrades_with_last_successful_read_preserved()
{
var (drv, _, _) = NewDriver(FocasStatusMapper.BadCommunicationError);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["raw/R101"], CancellationToken.None);
var lastGood = drv.GetHealth().LastSuccessfulRead;
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastSuccessfulRead.ShouldBe(lastGood);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Review minor 2 — a device that recovers from a comms fault and then only REFUSES (every tag it serves is
/// refused) is answering, so it is restored to Healthy instead of staying Degraded forever.
/// </summary>
[Fact]
public async Task Refusal_after_a_comms_fault_restores_healthy()
{
var (drv, client, _) = NewDriver(FocasStatusMapper.BadCommunicationError);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: the comms fault degraded it");
client.ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
await drv.ReadAsync(["raw/R100"], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A read whose CNC answer is held until released, then fails at the transport.</summary>
private sealed class HeldReadClient(TaskCompletionSource entered, Task release) : FakeFocasClient
{
public volatile bool Hold = true;
public override async Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
if (!Hold) return await base.ReadAsync(address, type, ct).ConfigureAwait(false);
entered.TrySetResult();
await release.ConfigureAwait(false);
throw new IOException("socket reset");
}
}
/// <summary>
/// Review minor 8 — a read that started against the PREVIOUS table and fails after a re-initialise must not
/// write its Degraded onto the new generation's health. Positive control: the same failure under the live
/// generation does degrade.
/// </summary>
[Fact]
public async Task A_read_from_the_previous_generation_cannot_degrade_the_new_one()
{
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var clients = new List<HeldReadClient>();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R100", Host, "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(10),
}, "focas-gen", new FakeFocasClientFactory
{
Customise = () => { var c = new HeldReadClient(entered, release.Task); lock (clients) clients.Add(c); return c; },
});
await drv.InitializeAsync("{}", CancellationToken.None);
var stale = drv.ReadAsync(["raw/R100"], CancellationToken.None);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
await drv.InitializeAsync("{}", CancellationToken.None); // a new generation, Healthy
release.SetResult();
(await stale.WaitAsync(TimeSpan.FromSeconds(5)))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "the old generation's failure must not land on the new one");
// Positive control: a failure under the live generation degrades.
var live = drv.ReadAsync(["raw/R100"], CancellationToken.None);
(await live.WaitAsync(TimeSpan.FromSeconds(5)))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A write the driver refuses (NotSupportedException) is not a comms fault either.</summary>
[Fact]
public async Task Write_refusal_does_not_degrade()
{
var client = new ThrowingWriteClient();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/W", Host, "R100", FocasDataType.Byte, Writable: true))],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-write-refusal", new FakeFocasClientFactory { Customise = () => client });
await drv.InitializeAsync("{}", CancellationToken.None);
var result = await drv.WriteAsync([new WriteRequest("raw/W", (sbyte)1)], CancellationToken.None);
result[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class ThrowingWriteClient : FakeFocasClient
{
public override Task<uint> WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken ct) =>
throw new NotSupportedException("bit writes are not supported on this area");
}
}
@@ -0,0 +1,104 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:U8 — <see cref="FocasDataType.String"/> was authorable on any address, but no area read
/// produces a string: PMC fell through to a byte read, PARAM to an Int32, MACRO to a double, and a
/// String-typed node received a boxed number at Good. String is now refused on PMC / PARAM / MACRO by the
/// one shared check, everywhere a tag is authored or loaded; the fixed tree (Identity / Program name, …),
/// whose values really are strings, keeps it.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasStringDataTypeTests
{
private const string Host = "focas://10.0.0.5:8193";
private static string Config(string address, string dataType) =>
$$"""{"address":"{{address}}","dataType":"{{dataType}}"}""";
/// <summary>String on an area read is refused by the mapper.</summary>
[Theory]
[InlineData("R100")]
[InlineData("X0.0")]
[InlineData("PARAM:6711")]
[InlineData("MACRO:500")]
public void Mapper_refuses_string_on_an_area_read(string address)
{
FocasTagDefinitionFactory.FromTagConfig(Config(address, "String"), "raw/T", out _).ShouldBeFalse();
FocasTagDefinitionFactory.DescribeDataTypeMismatch(FocasDataType.String, address).ShouldNotBeNull();
}
/// <summary>Positive control: numeric types on the same addresses, and String on the fixed tree, still map.</summary>
[Theory]
[InlineData("R100", "Byte")]
[InlineData("PARAM:6711", "Int32")]
[InlineData("MACRO:500", "Float64")]
[InlineData("FIXED:Identity/SeriesNumber", "String")]
[InlineData("FIXED:Program/Name", "String")]
public void Mapper_accepts_numeric_area_reads_and_string_fixed_tree_paths(string address, string dataType)
{
FocasTagDefinitionFactory.FromTagConfig(Config(address, dataType), "raw/T", out var def).ShouldBeTrue();
def.Address.ShouldBe(address);
}
/// <summary>An ALARM: tag has no value, so its data type is irrelevant and String is not refused.</summary>
[Fact]
public void Mapper_accepts_string_on_an_alarm_tag()
{
FocasTagDefinitionFactory.FromTagConfig(
"""{"address":"ALARM:*","dataType":"String","alarm":{"severity":500}}""", "raw/A", out _).ShouldBeTrue();
}
/// <summary>An unparseable address is left to the address check — it still draws THAT diagnosis.</summary>
[Fact]
public void Unparseable_address_keeps_its_own_diagnosis()
{
FocasTagDefinitionFactory.DescribeDataTypeMismatch(FocasDataType.String, "not-an-address").ShouldBeNull();
FocasTagDefinitionFactory.FromTagConfig(Config("not-an-address", "String"), "raw/T", out _).ShouldBeTrue();
}
/// <summary>The deploy-time inspector names the reason rather than falling to the generic backstop.</summary>
[Fact]
public void Inspect_names_the_string_refusal()
{
var warnings = FocasTagDefinitionFactory.Inspect(Config("MACRO:500", "String"));
warnings.ShouldContain(w => w.Contains("String", StringComparison.Ordinal) && w.Contains("MACRO:500", StringComparison.Ordinal));
FocasTagDefinitionFactory.Inspect(Config("MACRO:500", "Float64")).ShouldBeEmpty();
}
/// <summary>
/// At init a String area tag is skipped and reported (never served as a Good number); the numeric tag
/// beside it reads normally (positive control).
/// </summary>
[Fact]
public async Task Driver_skips_and_reports_a_string_area_tag()
{
var factory = new FakeFocasClientFactory
{
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } },
};
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
RawTags =
[
new RawTagEntry("raw/Str", Config("R100", "String"), false, Host),
new RawTagEntry("raw/Num", Config("R100", "Byte"), false, Host),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "focas-u8", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(["raw/Str", "raw/Num"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
snaps[0].Value.ShouldBeNull();
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[1].Value.ShouldBe((sbyte)7);
drv.GetSkippedTags()!.Sample.ShouldContain(s => s.RawPath == "raw/Str");
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,84 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #740 / R07:H14 — the capability matrix admits macro numbers up to 99999 (30i/31i/32i) and parameters up
/// to 29999 (Unknown: anything), and the FOCAS/2 request block carries 32-bit arguments, but the adapter
/// cast every number to <c>short</c>: <c>MACRO:99999</c> asked the CNC for <c>#-31073</c> and published
/// that variable Good under the authored tag. Numbers now travel as <c>int</c> end to end.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasWideNumberTests
{
/// <summary>The macro request carries the full number in both 32-bit argument slots.</summary>
[Theory]
[InlineData(500)]
[InlineData(32767)]
[InlineData(32768)]
[InlineData(99999)]
public void Macro_request_carries_the_unnarrowed_number(int number)
{
var block = FocasWireClient.MacroRequest(number, pathId: 1);
block.Command.ShouldBe((ushort)0x0015);
block.Arg1.ShouldBe(number);
block.Arg2.ShouldBe(number);
}
/// <summary>
/// Positive control on the wire encoding: the request for #500 is byte-identical to the one the real
/// 31i-B answered in the <c>ref-macro-500-0x0015</c> capture, so widening changed nothing below 32768.
/// </summary>
[Fact]
public void Macro_request_for_500_matches_the_live_capture_bytes()
{
var body = FocasWireProtocol.BuildRequestBody([FocasWireClient.MacroRequest(500, pathId: 1)]);
Convert.ToHexString(body).ShouldBe("0001001C0001000100150000 01F4000001F4000000000000000000000000".Replace(" ", ""));
}
/// <summary>A global parameter puts its number in both arguments; an axis parameter puts the axis second.</summary>
[Theory]
[InlineData(6711, (short)0, 6711)]
[InlineData(40000, (short)0, 40000)]
[InlineData(40000, (short)2, 2)]
public void Parameter_request_carries_the_unnarrowed_number(int number, short axis, int expectedArg2)
{
var block = FocasWireClient.ParameterRequest(number, axis, pathId: 1);
block.Command.ShouldBe((ushort)0x000e);
block.Arg1.ShouldBe(number);
block.Arg2.ShouldBe(expectedArg2);
}
/// <summary>The decoded macro reports the number that was asked for, not a wrapped one.</summary>
[Fact]
public void Parsed_macro_keeps_a_number_above_short_range()
{
var payload = new byte[] { 0x05, 0xF5, 0xE1, 0x00, 0x00, 0x0A, 0x00, 0x08 };
var m = FocasWireClient.ParseMacro(99999, payload);
m.Number.ShouldBe(99999);
m.Scaled.ShouldBe(1.0);
}
/// <summary>
/// pmc_rdpmcrng addresses are 16-bit on the wire; a PMC number whose slot runs past 65535 is refused
/// rather than wrapped onto a different address (Unknown series admits any number).
/// </summary>
[Theory]
[InlineData(100, 1, true)]
[InlineData(65535, 1, true)]
[InlineData(65534, 2, true)]
[InlineData(65535, 2, false)]
[InlineData(70000, 1, false)]
public void Pmc_range_outside_16_bits_is_refused_not_wrapped(int number, int width, bool ok)
{
WireFocasClient.TryPmcRange(number, width, out var start, out var end).ShouldBe(ok);
if (ok)
{
((int)start).ShouldBe(number);
((int)end).ShouldBe(number + width - 1);
}
}
}
@@ -169,4 +169,26 @@ public sealed class FocasTagConfigModelTests
[InlineData("""{"address":"R100","dataType":2}""")]
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
=> FocasTagConfigModel.FromJson(json).Validate().ShouldBeNull();
/// <summary>
/// #740 / R07:U8 — String on a PMC / PARAM / MACRO address is refused by the driver (no area read
/// returns text), so the modal refuses it too, for a model built in the editor and for a loaded blob.
/// </summary>
[Theory]
[InlineData("R100")]
[InlineData("PARAM:6711")]
[InlineData("MACRO:500")]
public void Validate_refuses_string_on_an_area_address(string address)
{
new FocasTagConfigModel { Address = address, DataType = FocasDataType.String }
.Validate().ShouldNotBeNull().ShouldContain("String");
FocasTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"String"}""")
.Validate().ShouldNotBeNull().ShouldContain("String");
}
/// <summary>Positive control: the fixed tree's string fields keep String.</summary>
[Fact]
public void Validate_accepts_string_on_a_fixed_tree_path()
=> new FocasTagConfigModel { Address = "FIXED:Identity/SeriesNumber", DataType = FocasDataType.String }
.Validate().ShouldBeNull();
}