Merge lane FOCAS: FOCAS driver remediation (#740)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -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);
|
||||
|
||||
+16
-2
@@ -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
|
||||
|
||||
+21
@@ -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>
|
||||
|
||||
+149
@@ -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()
|
||||
|
||||
+218
@@ -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<<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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user