Merge lane TwinCAT: TwinCAT driver remediation (#739)

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:04:51 -04:00
23 changed files with 3255 additions and 226 deletions
@@ -25,6 +25,11 @@ public sealed class ReadCommand : TwinCATTagCommandBase
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")] "String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt; public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
/// <summary>Gets the element count for an array read; null reads a scalar.</summary>
[CommandOption("array-length", Description =
"Read the symbol as a 1-D array of this many --type elements (the leading sub-range when shorter).")]
public int? ArrayLength { get; init; }
/// <inheritdoc /> /// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
@@ -38,7 +43,8 @@ public sealed class ReadCommand : TwinCATTagCommandBase
DeviceHostAddress: Gateway, DeviceHostAddress: Gateway,
SymbolPath: SymbolPath, SymbolPath: SymbolPath,
DataType: DataType, DataType: DataType,
Writable: false); Writable: false,
ArrayLength: ArrayLength);
var options = BuildOptions([tag]); var options = BuildOptions([tag]);
await using var driver = new TwinCATDriver(options, DriverInstanceId); await using var driver = new TwinCATDriver(options, DriverInstanceId);
@@ -47,6 +53,9 @@ public sealed class ReadCommand : TwinCATTagCommandBase
await driver.InitializeAsync("{}", ct); await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct); var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(SymbolPath, snapshot[0])); await console.Output.WriteLineAsync(SnapshotFormatter.Format(SymbolPath, snapshot[0]));
if (snapshot[0].Value is Array elements)
await console.Output.WriteLineAsync("Elements: [" + string.Join(", ",
elements.Cast<object?>().Select(e => Convert.ToString(e, System.Globalization.CultureInfo.InvariantCulture))) + "]");
} }
finally finally
{ {
@@ -30,6 +30,16 @@ public sealed class WriteCommand : TwinCATTagCommandBase
IsRequired = true)] IsRequired = true)]
public string Value { get; init; } = default!; public string Value { get; init; } = default!;
/// <summary>Gets the element count for an array write; null writes a scalar.</summary>
[CommandOption("array-length", Description =
"Write the symbol as a 1-D array of this many --type elements. --value then carries the elements " +
"separated by --separator (e.g. -v 1,2,3 --array-length 3); the count must match.")]
public int? ArrayLength { get; init; }
/// <summary>Gets the element separator for an array write.</summary>
[CommandOption("separator", Description = "Element separator for --array-length writes (default ',').")]
public string Separator { get; init; } = ",";
/// <inheritdoc /> /// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
@@ -48,10 +58,13 @@ public sealed class WriteCommand : TwinCATTagCommandBase
DeviceHostAddress: Gateway, DeviceHostAddress: Gateway,
SymbolPath: SymbolPath, SymbolPath: SymbolPath,
DataType: DataType, DataType: DataType,
Writable: true); Writable: true,
ArrayLength: ArrayLength);
var options = BuildOptions([tag]); var options = BuildOptions([tag]);
var parsed = ParseValue(Value, DataType); var parsed = ArrayLength is int count
? ParseArrayValue(Value, DataType, count, Separator)
: ParseValue(Value, DataType);
await using var driver = new TwinCATDriver(options, DriverInstanceId); await using var driver = new TwinCATDriver(options, DriverInstanceId);
try try
@@ -113,6 +126,27 @@ public sealed class WriteCommand : TwinCATTagCommandBase
} }
} }
/// <summary>
/// Parse an array <c>--value</c>: <paramref name="separator"/>-separated elements, each parsed by
/// <see cref="ParseValue"/>. The element count must equal <paramref name="count"/> — the driver
/// refuses a mismatch with BadOutOfRange, but the CLI says so before touching the PLC.
/// </summary>
/// <param name="raw">The raw separated element list.</param>
/// <param name="type">The element type.</param>
/// <param name="count">The expected element count (<c>--array-length</c>).</param>
/// <param name="separator">The element separator.</param>
/// <returns>The parsed elements.</returns>
internal static object[] ParseArrayValue(string raw, TwinCATDataType type, int count, string separator)
{
if (count < 1)
throw new CliFx.Exceptions.CommandException("--array-length must be >= 1.");
var parts = raw.Split(string.IsNullOrEmpty(separator) ? "," : separator);
if (parts.Length != count)
throw new CliFx.Exceptions.CommandException(
$"--value has {parts.Length} element(s) but --array-length is {count}.");
return [.. parts.Select(p => ParseValue(type is TwinCATDataType.String or TwinCATDataType.WString ? p : p.Trim(), type))];
}
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
{ {
"1" or "true" or "on" or "yes" => true, "1" or "true" or "on" or "yes" => true,
@@ -469,6 +469,20 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
{ {
try try
{ {
if (type is TwinCATDataType.String or TwinCATDataType.WString)
{
// A STRING / WSTRING is written as raw bytes at the PLC's own declared width, encoded by the
// same codec the read decodes with: Windows-1252 (or UTF-8 when the target says so) rather
// than the host's ANSI code page, NUL-padded so no stale tail survives, and refused
// (OverflowException → BadOutOfRange) when it does not fit STRING(n).
var (shape, _, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) return symbolStatus;
var (planStatus, encoded) = PlanStringWrite(type, shape, value, StringsAreUtf8());
if (planStatus != TwinCATStatusMapper.Good) return planStatus;
return await WriteRawAsync(symbolPath, symbol!, encoded, cancellationToken).ConfigureAwait(false);
}
var converted = ConvertForWrite(type, value); var converted = ConvertForWrite(type, value);
var result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken) var result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -482,6 +496,123 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
} }
} }
/// <summary>
/// The pure encode plan of a STRING / WSTRING write against the RESOLVED symbol: the declared kind must
/// match the PLC's (<see cref="TwinCATTypeGuard.CheckRead"/>, else <c>BadTypeMismatch</c>), then the value is
/// encoded at the PLC's byte width by <see cref="TwinCATStringCodec"/>.
/// </summary>
/// <param name="type">The declared String / WString type.</param>
/// <param name="shape">The resolved PLC symbol shape.</param>
/// <param name="value">The written value.</param>
/// <param name="utf8">True when the target reports UTF-8 STRING data.</param>
/// <returns>Good with the bytes to write, or the refusal status with no bytes.</returns>
/// <exception cref="OverflowException">The value does not fit the PLC string.</exception>
internal static (uint status, byte[] bytes) PlanStringWrite(
TwinCATDataType type, TwinCATPlcSymbolShape shape, object? value, bool utf8)
{
if (TwinCATTypeGuard.CheckRead(type, shape) is not null) return (TwinCATStatusMapper.BadTypeMismatch, []);
var text = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
return (TwinCATStatusMapper.Good, type == TwinCATDataType.WString
? TwinCATStringCodec.EncodeWString(text, shape.ByteSize)
: TwinCATStringCodec.EncodeString(text, shape.ByteSize, utf8));
}
/// <summary>
/// The pure encode plan of an array write against the RESOLVED PLC array: the layout comes from
/// <see cref="TwinCATTypeGuard.CheckArray"/> (the array read's rule, else <c>BadTypeMismatch</c>) and the
/// bytes from <see cref="TwinCATArrayCodec.Encode"/>.
/// </summary>
/// <param name="type">The declared element type.</param>
/// <param name="elements">The converted elements.</param>
/// <param name="shape">The resolved PLC symbol shape.</param>
/// <param name="array">The resolved PLC array shape, or null for a non-array symbol.</param>
/// <param name="utf8">True when the target reports UTF-8 STRING data.</param>
/// <returns>Good with the bytes to write, or the refusal status with no bytes.</returns>
/// <exception cref="OverflowException">A string element does not fit its stride.</exception>
internal static (uint status, byte[] bytes) PlanArrayWrite(TwinCATDataType type, IReadOnlyList<object> elements,
TwinCATPlcSymbolShape shape, TwinCATPlcArrayShape? array, bool utf8)
{
if (TwinCATTypeGuard.CheckArray(type, elements.Count, shape, array, out var layout) is not null)
return (TwinCATStatusMapper.BadTypeMismatch, []);
return (TwinCATStatusMapper.Good, TwinCATArrayCodec.Encode(type, elements, layout, utf8));
}
/// <inheritdoc />
public async Task<uint> WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList<object> elements,
CancellationToken cancellationToken)
{
try
{
// Plan the layout from the RESOLVED PLC array exactly as the array read does, so the element
// stride (incl. a STRING(n) element's n+1) and the strict element-type rule are shared.
var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) return symbolStatus;
var (planStatus, bytes) = PlanArrayWrite(type, elements, shape, array, StringsAreUtf8());
if (planStatus != TwinCATStatusMapper.Good) return planStatus;
return await WriteRawAsync(symbolPath, symbol!, bytes, cancellationToken).ConfigureAwait(false);
}
catch (AdsErrorException ex)
{
return MapAndSignal((uint)ex.ErrorCode);
}
}
/// <inheritdoc />
public async Task<uint> WriteBitAsync(string parentPath, int bit, bool value, CancellationToken cancellationToken)
{
try
{
// Resolve the parent so the RMW happens at its OWN width (the notification path's model):
// the old 32-bit UDInt round-trip wrote a 16-bit parent's neighbours and overflowed on a
// signed parent with its sign bit set.
var (parent, _, symbol, status) = await DescribeSymbolAsync(parentPath, cancellationToken)
.ConfigureAwait(false);
if (status != TwinCATStatusMapper.Good) return status;
if (TwinCATBitAccess.CheckParent(bit, parent) is not null)
return TwinCATBitAccess.ApplyBitWrite(parent, ReadOnlySpan<byte>.Empty, bit, value, out _);
var (bytes, readStatus) = await ReadRawAsync(parentPath, symbol!, parent.ByteSize, cancellationToken)
.ConfigureAwait(false);
if (readStatus != TwinCATStatusMapper.Good) return readStatus;
var modify = TwinCATBitAccess.ApplyBitWrite(parent, bytes, bit, value, out var updated);
if (modify != TwinCATStatusMapper.Good) return modify;
return await WriteRawAsync(parentPath, symbol!, updated, cancellationToken).ConfigureAwait(false);
}
catch (AdsErrorException ex)
{
return MapAndSignal((uint)ex.ErrorCode);
}
}
/// <summary>
/// Writes raw bytes to a resolved symbol — the mirror of <see cref="ReadRawAsync"/>: by its index
/// group/offset, or through a fresh variable handle for a handle-addressed symbol (0xF005).
/// </summary>
private async Task<uint> WriteRawAsync(
string symbolPath, IAdsSymbol symbol, ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
if (symbol.IndexGroup != SymbolValueByHandle)
{
var direct = await _client.WriteAsync(symbol.IndexGroup, symbol.IndexOffset, data, cancellationToken)
.ConfigureAwait(false);
return direct.ErrorCode == AdsErrorCode.NoError ? TwinCATStatusMapper.Good : MapAndSignal((uint)direct.ErrorCode);
}
var handle = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
if (handle.ErrorCode != AdsErrorCode.NoError) return MapAndSignal((uint)handle.ErrorCode);
try
{
var write = await _client.WriteAsync(handle.Handle, data, cancellationToken).ConfigureAwait(false);
return write.ErrorCode == AdsErrorCode.NoError ? TwinCATStatusMapper.Good : MapAndSignal((uint)write.ErrorCode);
}
finally
{
try { await _client.DeleteVariableHandleAsync(handle.Handle, CancellationToken.None).ConfigureAwait(false); }
catch { /* best-effort release */ }
}
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<bool> ProbeAsync(CancellationToken cancellationToken) public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
{ {
@@ -523,8 +654,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var (parent, _, _, parentStatus) = await DescribeSymbolAsync(registerPath, cancellationToken) var (parent, _, _, parentStatus) = await DescribeSymbolAsync(registerPath, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (parentStatus != TwinCATStatusMapper.Good) if (parentStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException( throw RegistrationFailure(registerPath, "resolving the parent word", parentStatus);
$"Resolving {registerPath} for a notification failed with status 0x{parentStatus:X8}");
if (TwinCATBitAccess.CheckParent(bit, parent) is { } bitReason) if (TwinCATBitAccess.CheckParent(bit, parent) is { } bitReason)
throw new TwinCATTypeMismatchException(symbolPath, bitReason); throw new TwinCATTypeMismatchException(symbolPath, bitReason);
clrType = typeof(byte[]); clrType = typeof(byte[]);
@@ -538,8 +668,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var (shape, array, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken) var (shape, array, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) if (symbolStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException( throw RegistrationFailure(symbolPath, "resolving the symbol", symbolStatus);
$"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}");
if (arrayCount is int count) if (arrayCount is int count)
{ {
@@ -582,8 +711,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
registerPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken) registerPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError) if (result.ErrorCode != AdsErrorCode.NoError)
throw new InvalidOperationException( throw RegistrationFailure(registerPath, $"AddDeviceNotificationEx (ADS error {result.ErrorCode})",
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {registerPath}"); MapAndSignal((uint)result.ErrorCode));
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle) var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
{ {
@@ -594,6 +723,17 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return reg; return reg;
} }
/// <summary>
/// Classifies a failed notification registration. A status proving the device ANSWERED (symbol not
/// found, invalid access, PLC not ready, …) is a per-tag refusal the driver isolates without a health
/// write; a comms-class status (<c>BadCommunicationError</c> / <c>BadTimeout</c>) stays an ordinary
/// exception — a comms fault the driver degrades on and retries.
/// </summary>
internal static Exception RegistrationFailure(string symbolPath, string step, uint status) =>
status is TwinCATStatusMapper.BadCommunicationError or TwinCATStatusMapper.BadTimeout
? new InvalidOperationException($"{step} for {symbolPath} failed with status 0x{status:X8}")
: new TwinCATNotificationRefusedException(symbolPath, $"{step} failed with status 0x{status:X8}", status);
/// <summary> /// <summary>
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap decode (bit /// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap decode (bit
/// extraction, string, array) then enqueues — no driver logic, no consumer callbacks — so a slow consumer /// extraction, string, array) then enqueues — no driver logic, no consumer callbacks — so a slow consumer
@@ -618,15 +758,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
value = DecodeString(reg.Type, raw, reg.StringsUtf8); value = DecodeString(reg.Type, raw, reg.StringsUtf8);
else if (TwinCATTemporalCodec.IsTemporal(reg.Type) && value is uint rawTemporal) else if (TwinCATTemporalCodec.IsTemporal(reg.Type) && value is uint rawTemporal)
value = TwinCATTemporalCodec.Decode(reg.Type, rawTemporal); value = TwinCATTemporalCodec.Decode(reg.Type, rawTemporal);
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value)); if (!_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value)))
Interlocked.Increment(ref _droppedNotifications); // queue full (DropWrite): count, never block
} }
private long _droppedNotifications;
/// <inheritdoc />
public long DroppedNotificationCount => Interlocked.Read(ref _droppedNotifications);
private async Task DispatchLoopAsync(CancellationToken ct) private async Task DispatchLoopAsync(CancellationToken ct)
{ {
try try
{ {
await foreach (var pending in _notificationQueue.Reader.ReadAllAsync(ct).ConfigureAwait(false)) await foreach (var pending in _notificationQueue.Reader.ReadAllAsync(ct).ConfigureAwait(false))
{ {
// A value queued before its registration was deleted is purged here rather than delivered
// after the unsubscribe.
if (pending.Registration.IsDeleted) continue;
try { pending.Registration.OnChange(pending.Registration.SymbolPath, pending.Value); } try { pending.Registration.OnChange(pending.Registration.SymbolPath, pending.Value); }
catch { /* consumer-side errors stay on this managed task, not the ADS thread */ } catch { /* consumer-side errors stay on this managed task, not the ADS thread */ }
} }
@@ -877,9 +1026,15 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// </summary> /// </summary>
public void Dispose() public void Dispose()
{ {
IsDeleted = true;
// Fire-and-forget AMS call — caller has already committed to the tear-down. // Fire-and-forget AMS call — caller has already committed to the tear-down.
_ = owner.DeleteNotificationAsync(handle, CancellationToken.None); _ = owner.DeleteNotificationAsync(handle, CancellationToken.None);
} }
/// <summary>Gets a value indicating whether the registration was disposed; queued values for it are dropped.</summary>
public bool IsDeleted { get => Volatile.Read(ref _deleted); private set => Volatile.Write(ref _deleted, value); }
private bool _deleted;
} }
private static Type MapToClrType(TwinCATDataType type) => type switch private static Type MapToClrType(TwinCATDataType type) => type switch
@@ -916,14 +1071,43 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
TwinCATDataType.UDInt => Convert.ToUInt32(value), TwinCATDataType.UDInt => Convert.ToUInt32(value),
TwinCATDataType.LInt => Convert.ToInt64(value), TwinCATDataType.LInt => Convert.ToInt64(value),
TwinCATDataType.ULInt => Convert.ToUInt64(value), TwinCATDataType.ULInt => Convert.ToUInt64(value),
TwinCATDataType.Real => Convert.ToSingle(value), TwinCATDataType.Real => ToSingleInRange(value),
TwinCATDataType.LReal => Convert.ToDouble(value), TwinCATDataType.LReal => ToDoubleInRange(value),
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty, TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
TwinCATDataType.Time or TwinCATDataType.Date TwinCATDataType.Time or TwinCATDataType.Date
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => TwinCATTemporalCodec.Encode(type, value), or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => TwinCATTemporalCodec.Encode(type, value),
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."), _ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
}; };
// Convert.ToSingle(1e300) is +Infinity, and Convert.ToDouble("1e400") too: writing that to the PLC
// would store a value nobody wrote. A finite source that overflows the target is out of range; an
// explicit infinity (or NaN) passes through as the caller's own value.
private static float ToSingleInRange(object? value)
{
var f = Convert.ToSingle(value, System.Globalization.CultureInfo.InvariantCulture);
if (float.IsInfinity(f) && !IsExplicitInfinity(value))
throw new OverflowException($"{value} is outside the REAL range (±{float.MaxValue:R})");
return f;
}
private static double ToDoubleInRange(object? value)
{
var d = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture);
if (double.IsInfinity(d) && !IsExplicitInfinity(value))
throw new OverflowException($"{value} is outside the LREAL range");
return d;
}
// An explicit infinity — a float/double infinity, or a string that SPELLS one ("Infinity", "-inf", "∞")
// — is the caller's own value and passes; a finite number that overflows ("1e400") does not.
private static bool IsExplicitInfinity(object? value) => value switch
{
float f => float.IsInfinity(f),
double d => double.IsInfinity(d),
string s => s.Contains("inf", StringComparison.OrdinalIgnoreCase) || s.Contains('∞'),
_ => false,
};
private static bool ExtractBit(object? rawWord, int bit) => rawWord switch private static bool ExtractBit(object? rawWord, int bit) => rawWord switch
{ {
short s => (s & (1 << bit)) != 0, short s => (s & (1 << bit)) != 0,
@@ -102,6 +102,12 @@ public interface ITwinCATClient : IDisposable
/// <summary>The overall ADS code of the most recent such fallback (0 = a malformed response).</summary> /// <summary>The overall ADS code of the most recent such fallback (0 = a malformed response).</summary>
uint LastSumFallbackCode => 0; uint LastSumFallbackCode => 0;
/// <summary>
/// Native notifications this client dropped because its bounded dispatch queue was full (the router
/// thread never blocks on a slow consumer). The driver warns when it grows. Default 0.
/// </summary>
long DroppedNotificationCount => 0;
/// <summary> /// <summary>
/// Write a symbolic value. Returns the mapped OPC UA status for the operation /// Write a symbolic value. Returns the mapped OPC UA status for the operation
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>). /// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
@@ -119,6 +125,39 @@ public interface ITwinCATClient : IDisposable
object? value, object? value,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>
/// Writes an array tag: <paramref name="elements"/> (already converted to the declared element type by
/// <see cref="TwinCATArrayCodec.ToWriteElements"/>) as raw bytes at the layout planned from the RESOLVED
/// PLC array (<see cref="TwinCATTypeGuard.CheckArray"/>) and encoded by
/// <see cref="TwinCATArrayCodec.Encode"/> — the exact inverse of the array read, so an array tag writes
/// what it reads. Fewer elements than the PLC array writes its leading sub-range.
/// </summary>
/// <param name="symbolPath">The ADS symbol path of the array.</param>
/// <param name="type">The declared element type.</param>
/// <param name="elements">The converted elements (the authored <c>ArrayLength</c> of them).</param>
/// <param name="cancellationToken">Cancellation token for the write.</param>
/// <returns>The mapped OPC UA status: Good; <c>BadTypeMismatch</c> when the declared element type /
/// length cannot address the PLC array; the ADS write's status otherwise.</returns>
/// <exception cref="OverflowException">A string element does not fit the PLC element's width.</exception>
Task<uint> WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList<object> elements,
CancellationToken cancellationToken);
/// <summary>
/// Sets or clears one bit of an integer parent word — the write half of a BOOL-within-word tag
/// (<c>MAIN.Flags.3</c>; ADS has no symbol for <c>WORD.N</c>). A read-modify-write of the parent's raw
/// bytes at the parent's OWN resolved width (<see cref="TwinCATBitAccess.ApplyBitWrite"/>), so a 16-bit
/// parent is never written as 32 bits and a signed parent's sign bit is just a bit. The caller
/// serialises concurrent writers to the same parent; the PLC program writing the word between the
/// read and the write is inherent to RMW.
/// </summary>
/// <param name="parentPath">The parent word's ADS symbol path (the bit path without its <c>.N</c>).</param>
/// <param name="bit">The bit index (0 = least significant bit of the first byte).</param>
/// <param name="value">The bit value to write.</param>
/// <param name="cancellationToken">Cancellation token for the read and the write.</param>
/// <returns>The mapped OPC UA status: Good; the parent read's status; <c>BadOutOfRange</c> for a bit
/// beyond the parent; <c>BadTypeMismatch</c> for a parent that is not an integer word.</returns>
Task<uint> WriteBitAsync(string parentPath, int bit, bool value, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable. /// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable.
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop. /// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
@@ -19,6 +19,84 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// </remarks> /// </remarks>
internal static class TwinCATArrayCodec internal static class TwinCATArrayCodec
{ {
/// <summary>
/// Converts a client-supplied array value into exactly <paramref name="count"/> elements of the
/// declared type, each converted the way a scalar write converts (<see cref="AdsTwinCATClient.ConvertForWrite"/>
/// — so TIME/DATE elements become their encoded <c>uint</c>). Any CLR collection is accepted and the
/// element type is CONVERTED, not exact-matched: ScadaBridge writes an <c>int[]</c> to an Int16 node.
/// </summary>
/// <param name="declared">The tag's declared element type.</param>
/// <param name="value">The written value — an array or list.</param>
/// <param name="count">The tag's authored <c>ArrayLength</c>.</param>
/// <returns>The converted elements, in order.</returns>
/// <exception cref="InvalidCastException">The value is not a collection (a scalar, a string, null), or
/// an element cannot convert.</exception>
/// <exception cref="OverflowException">The element count differs from <paramref name="count"/>, or an
/// element is outside the declared type's range.</exception>
public static object[] ToWriteElements(TwinCATDataType declared, object? value, int count)
{
if (value is null or string || value is not System.Collections.IEnumerable items)
throw new InvalidCastException(
$"an array tag of {count} {declared} takes an array value, not {value?.GetType().Name ?? "null"}");
var elements = new List<object>(count);
foreach (var item in items) elements.Add(AdsTwinCATClient.ConvertForWrite(declared, item));
if (elements.Count != count)
throw new OverflowException($"wrote {elements.Count} elements to an array tag of arrayLength {count}");
return [.. elements];
}
/// <summary>
/// Encodes converted elements (<see cref="ToWriteElements"/>) into the PLC's raw bytes at the planned
/// layout — the exact inverse of <see cref="Decode"/>: little-endian at the element stride, a
/// STRING / WSTRING element encoded by <see cref="TwinCATStringCodec"/> within its fixed stride.
/// </summary>
/// <param name="declared">The tag's declared element type.</param>
/// <param name="elements">The converted elements; exactly <see cref="TwinCATArrayLayout.ElementCount"/>.</param>
/// <param name="layout">The layout planned from the PLC array (<see cref="TwinCATTypeGuard.CheckArray"/>).</param>
/// <param name="utf8">True when the target reports UTF-8 STRING data (else Windows-1252).</param>
/// <returns>Exactly <see cref="TwinCATArrayLayout.ByteLength"/> bytes.</returns>
/// <exception cref="ArgumentException">The element count differs from the layout.</exception>
/// <exception cref="OverflowException">A string element does not fit its stride.</exception>
public static byte[] Encode(TwinCATDataType declared, IReadOnlyList<object> elements, TwinCATArrayLayout layout, bool utf8)
{
if (layout.Stride <= 0 || elements.Count != layout.ElementCount)
throw new ArgumentException(
$"{elements.Count} elements do not match a layout of {layout.ElementCount} × {layout.Stride} B", nameof(elements));
var bytes = new byte[layout.ByteLength];
var stride = layout.Stride;
for (var i = 0; i < elements.Count; i++)
{
var slot = bytes.AsSpan(i * stride, stride);
var e = elements[i];
switch (declared)
{
case TwinCATDataType.Bool: slot[0] = Convert.ToBoolean(e) ? (byte)1 : (byte)0; break;
case TwinCATDataType.SInt: slot[0] = unchecked((byte)Convert.ToSByte(e)); break;
case TwinCATDataType.USInt: slot[0] = Convert.ToByte(e); break;
case TwinCATDataType.Int: BinaryPrimitives.WriteInt16LittleEndian(slot, Convert.ToInt16(e)); break;
case TwinCATDataType.UInt: BinaryPrimitives.WriteUInt16LittleEndian(slot, Convert.ToUInt16(e)); break;
case TwinCATDataType.DInt: BinaryPrimitives.WriteInt32LittleEndian(slot, Convert.ToInt32(e)); break;
case TwinCATDataType.UDInt or TwinCATDataType.Time or TwinCATDataType.TimeOfDay
or TwinCATDataType.Date or TwinCATDataType.DateTime:
BinaryPrimitives.WriteUInt32LittleEndian(slot, Convert.ToUInt32(e)); break;
case TwinCATDataType.LInt: BinaryPrimitives.WriteInt64LittleEndian(slot, Convert.ToInt64(e)); break;
case TwinCATDataType.ULInt: BinaryPrimitives.WriteUInt64LittleEndian(slot, Convert.ToUInt64(e)); break;
case TwinCATDataType.Real: BinaryPrimitives.WriteSingleLittleEndian(slot, Convert.ToSingle(e)); break;
case TwinCATDataType.LReal: BinaryPrimitives.WriteDoubleLittleEndian(slot, Convert.ToDouble(e)); break;
case TwinCATDataType.String:
TwinCATStringCodec.EncodeString(Convert.ToString(e) ?? string.Empty, stride, utf8).CopyTo(slot);
break;
case TwinCATDataType.WString:
TwinCATStringCodec.EncodeWString(Convert.ToString(e) ?? string.Empty, stride).CopyTo(slot);
break;
default:
throw new NotSupportedException($"TwinCATDataType {declared} has no array element encoding");
}
}
return bytes;
}
/// <summary>Decodes <paramref name="layout"/>.ElementCount elements of <paramref name="declared"/>.</summary> /// <summary>Decodes <paramref name="layout"/>.ElementCount elements of <paramref name="declared"/>.</summary>
/// <param name="declared">The tag's declared element type.</param> /// <param name="declared">The tag's declared element type.</param>
/// <param name="bytes">The PLC bytes from the array's first element (at least <see cref="TwinCATArrayLayout.ByteLength"/>).</param> /// <param name="bytes">The PLC bytes from the array's first element (at least <see cref="TwinCATArrayLayout.ByteLength"/>).</param>
@@ -32,6 +32,36 @@ internal static class TwinCATBitAccess
: $"bit {bit} is outside the {parent.ByteSize * 8}-bit parent {parent.TypeName}"; : $"bit {bit} is outside the {parent.ByteSize * 8}-bit parent {parent.TypeName}";
} }
/// <summary>
/// The modify step of a bit write's read-modify-write: checks the bit against the parent
/// (<see cref="CheckParent"/>) and returns the parent's bytes — exactly <c>parent.ByteSize</c> of them —
/// with one bit set or cleared. Works on raw bytes, so the parent's width and signedness play no part.
/// </summary>
/// <param name="parent">The parent symbol's resolved shape.</param>
/// <param name="current">The parent's current bytes as read from the PLC.</param>
/// <param name="bit">The bit index.</param>
/// <param name="value">The bit value.</param>
/// <param name="updated">The bytes to write back when the result is Good; otherwise empty.</param>
/// <returns>Good; <c>BadOutOfRange</c> for a bit beyond the parent; <c>BadTypeMismatch</c> for a parent
/// that is not an integer word; <c>BadCommunicationError</c> for a read shorter than the parent
/// (writing it back would zero the missing bytes).</returns>
public static uint ApplyBitWrite(TwinCATPlcSymbolShape parent, ReadOnlySpan<byte> current, int bit, bool value,
out byte[] updated)
{
updated = [];
if (CheckParent(bit, parent) is not null)
return parent.Kind != TwinCATPlcKind.Scalar || parent.IsFloat
? TwinCATStatusMapper.BadTypeMismatch
: TwinCATStatusMapper.BadOutOfRange;
if (current.Length < parent.ByteSize) return TwinCATStatusMapper.BadCommunicationError;
var bytes = current[..parent.ByteSize].ToArray();
var mask = (byte)(1 << (bit % 8));
bytes[bit / 8] = value ? (byte)(bytes[bit / 8] | mask) : (byte)(bytes[bit / 8] & ~mask);
updated = bytes;
return TwinCATStatusMapper.Good;
}
/// <summary>Reads bit <paramref name="bit"/> of a little-endian parent word.</summary> /// <summary>Reads bit <paramref name="bit"/> of a little-endian parent word.</summary>
/// <param name="parent">The parent word's raw bytes.</param> /// <param name="parent">The parent word's raw bytes.</param>
/// <param name="bit">The bit index (0 = least significant bit of the first byte).</param> /// <param name="bit">The bit index (0 = least significant bit of the first byte).</param>
File diff suppressed because it is too large Load Diff
@@ -50,6 +50,57 @@ internal static class TwinCATStringCodec
return (utf8 ? Encoding.UTF8 : Windows1252).GetString(bytes); return (utf8 ? Encoding.UTF8 : Windows1252).GetString(bytes);
} }
// Strict encoders for the WRITE side: a character the target encoding cannot hold is refused rather
// than silently replaced with '?' (the replacement would be written to the PLC as if it were the value).
private static readonly Encoding StrictWindows1252 = CreateStrict(Windows1252.CodePage);
private static readonly Encoding StrictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
private static readonly Encoding StrictUtf16 = new UnicodeEncoding(bigEndian: false, byteOrderMark: false, throwOnInvalidBytes: true);
private static Encoding CreateStrict(int codePage) =>
Encoding.GetEncoding(codePage, EncoderFallback.ExceptionFallback, DecoderFallback.ReplacementFallback);
/// <summary>
/// Encodes a value into an IEC <c>STRING(n)</c> buffer of the PLC's declared width (n+1 bytes): the
/// inverse of <see cref="DecodeString"/> — Windows-1252 unless <paramref name="utf8"/>, NUL-terminated
/// and NUL-padded so no stale tail of a longer earlier value survives.
/// </summary>
/// <param name="value">The value to write.</param>
/// <param name="byteWidth">The PLC symbol's byte width (n+1 for a <c>STRING(n)</c>).</param>
/// <param name="utf8">True when the target reports UTF-8 string data.</param>
/// <returns>Exactly <paramref name="byteWidth"/> bytes.</returns>
/// <exception cref="OverflowException">The value does not fit <c>STRING(n)</c>, or holds a character the
/// target encoding cannot represent.</exception>
public static byte[] EncodeString(string value, int byteWidth, bool utf8) =>
Encode(value, byteWidth, utf8 ? StrictUtf8 : StrictWindows1252, terminatorBytes: 1, "STRING");
/// <summary>
/// Encodes a value into an IEC <c>WSTRING(n)</c> buffer (UTF-16LE, 2(n+1) bytes): the inverse of
/// <see cref="DecodeWString"/>, terminated by an aligned <c>U+0000</c> and zero-padded.
/// </summary>
/// <param name="value">The value to write.</param>
/// <param name="byteWidth">The PLC symbol's byte width.</param>
/// <returns>Exactly <paramref name="byteWidth"/> bytes.</returns>
/// <exception cref="OverflowException">The value does not fit <c>WSTRING(n)</c>.</exception>
public static byte[] EncodeWString(string value, int byteWidth) =>
Encode(value, byteWidth, StrictUtf16, terminatorBytes: 2, "WSTRING");
private static byte[] Encode(string value, int byteWidth, Encoding encoding, int terminatorBytes, string kind)
{
ArgumentNullException.ThrowIfNull(value);
byte[] encoded;
try { encoded = encoding.GetBytes(value); }
catch (ArgumentException ex) // EncoderFallbackException: a character the encoding cannot hold
{
throw new OverflowException($"'{value}' cannot be represented in the PLC's {kind} encoding: {ex.Message}", ex);
}
if (encoded.Length + terminatorBytes > byteWidth)
throw new OverflowException(
$"'{value}' is {encoded.Length} B but the PLC {kind} holds {Math.Max(0, byteWidth - terminatorBytes)} B");
var buffer = new byte[byteWidth];
encoded.CopyTo(buffer, 0);
return buffer;
}
/// <summary>Decodes an IEC <c>WSTRING(n)</c> buffer (UTF-16LE): ends at the first aligned <c>U+0000</c>.</summary> /// <summary>Decodes an IEC <c>WSTRING(n)</c> buffer (UTF-16LE): ends at the first aligned <c>U+0000</c>.</summary>
/// <param name="bytes">The raw symbol bytes (2(n+1) for a <c>WSTRING(n)</c>).</param> /// <param name="bytes">The raw symbol bytes (2(n+1) for a <c>WSTRING(n)</c>).</param>
/// <returns>The decoded value.</returns> /// <returns>The decoded value.</returns>
@@ -184,4 +184,24 @@ public sealed class WriteCommandParseValueTests
{ {
ReadCommand.SynthesiseTagName(symbol, type).ShouldBe(expected); ReadCommand.SynthesiseTagName(symbol, type).ShouldBe(expected);
} }
/// <summary>An array value splits on the separator and parses each element per --type (#739 U9 gate tooling).</summary>
[Fact]
public void ParseArrayValue_parses_each_element()
{
WriteCommand.ParseArrayValue("1, -2,3", TwinCATDataType.Int, 3, ",")
.ShouldBe(new object[] { (short)1, (short)-2, (short)3 });
WriteCommand.ParseArrayValue("a b|c", TwinCATDataType.String, 2, "|")
.ShouldBe(new object[] { "a b", "c" });
}
/// <summary>A count that differs from --array-length is refused before the PLC is touched.</summary>
[Fact]
public void ParseArrayValue_refuses_a_count_mismatch()
{
Should.Throw<CliFx.Exceptions.CommandException>(
() => WriteCommand.ParseArrayValue("1,2", TwinCATDataType.Int, 3, ","));
Should.Throw<CliFx.Exceptions.CommandException>(
() => WriteCommand.ParseArrayValue("1", TwinCATDataType.Int, 0, ","));
}
} }
@@ -0,0 +1,146 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
/// <summary>
/// Wire-level cases for the #739 High/Medium findings, against the live TwinCAT 3 XAR fixture
/// (skipped without it, like <see cref="TwinCAT3SmokeTests"/>). Every case that writes restores what it
/// changed: <c>GVL_Primitives.vWord</c> and <c>vString</c> are seeds the primitives theory reads.
/// </summary>
[Collection("TwinCATXar")]
[Trait("Category", "Integration")]
[Trait("Simulator", "TwinCAT-XAR")]
public sealed class TwinCATRemediationGateTests(TwinCATXarFixture sim)
{
private string Host => $"ads://{sim.TargetNetId}:{sim.AmsPort}";
private TwinCATDriver NewDriver(string id, bool native, params TwinCATTagDefinition[] tags) => new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host, "XAR-VM")],
RawTags = TwinCATRawTags.Entries(tags),
UseNativeNotifications = native,
Timeout = TimeSpan.FromSeconds(5),
Probe = new TwinCATProbeOptions { Enabled = false },
}, id);
/// <summary>H11: a typo'd symbol in a native subscribe is isolated; the real counter keeps delivering.</summary>
[TwinCATFact]
public async Task Native_subscribe_isolates_an_unknown_symbol()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-h11", native: true,
new TwinCATTagDefinition("Counter", Host, "GVL_Fixture.nCounter", TwinCATDataType.DInt),
new TwinCATTagDefinition("Typo", Host, "GVL_Fixture.nCounterTypo", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Counter", "Typo"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (events.Count(e => e.FullReference == "Counter") < 2 && DateTime.UtcNow < deadline)
await Task.Delay(50, TestContext.Current.CancellationToken);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
events.Count(e => e.FullReference == "Counter" && e.Snapshot.StatusCode == 0u).ShouldBeGreaterThanOrEqualTo(2);
events.First(e => e.FullReference == "Typo").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refused symbol is not a comms fault");
}
/// <summary>H12: native pushes alone advance LastSuccessfulRead.</summary>
[TwinCATFact]
public async Task Native_pushes_advance_LastSuccessfulRead()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-h12", native: true,
new TwinCATTagDefinition("Counter", Host, "GVL_Fixture.nCounter", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
var handle = await drv.SubscribeAsync(["Counter"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
await Task.Delay(1500, TestContext.Current.CancellationToken);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp.AddMilliseconds(500));
}
/// <summary>
/// H18: a bit write on the 16-bit <c>vWord</c> (0xBEEF) flips exactly that bit at the WORD's own width
/// (the old 32-bit UDInt RMW could not even address a WORD), and a bit beyond it is refused.
/// </summary>
[TwinCATFact]
public async Task Bit_write_on_a_WORD_flips_one_bit_and_refuses_bit_16()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-h18", native: false,
new TwinCATTagDefinition("Word", Host, "GVL_Primitives.vWord", TwinCATDataType.UInt, Writable: false),
new TwinCATTagDefinition("Bit4", Host, "GVL_Primitives.vWord.4", TwinCATDataType.Bool),
new TwinCATTagDefinition("Bit16", Host, "GVL_Primitives.vWord.16", TwinCATDataType.Bool));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var before = Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value);
var bit4WasSet = (before & 0x10) != 0;
try
{
(await drv.WriteAsync([new WriteRequest("Bit4", !bit4WasSet)], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(0u);
var after = Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value);
after.ShouldBe((ushort)(before ^ 0x10), "only bit 4 may change");
(await drv.WriteAsync([new WriteRequest("Bit16", true)], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
}
finally
{
await drv.WriteAsync([new WriteRequest("Bit4", bit4WasSet)], CancellationToken.None);
}
Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value).ShouldBe(before);
}
/// <summary>U9: a whole-array write round-trips through the codec (CLR double[] converts to REAL elements).</summary>
[TwinCATFact]
public async Task Whole_array_write_round_trips()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-u9-array", native: false,
new TwinCATTagDefinition("Arr", Host, "GVL_Arrays.aReal1D", TwinCATDataType.Real, ArrayLength: 32));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var values = Enumerable.Range(0, 32).Select(i => i * 1.5 + 0.25).ToArray();
(await drv.WriteAsync([new WriteRequest("Arr", values)], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(0u);
var read = (await drv.ReadAsync(["Arr"], TestContext.Current.CancellationToken))[0];
read.StatusCode.ShouldBe(0u);
((float[])read.Value!).ShouldBe(values.Select(v => (float)v).ToArray());
(await drv.WriteAsync([new WriteRequest("Arr", values[..31])], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
}
/// <summary>U9: a STRING write is NUL-padded at the PLC width (no stale tail) and an over-long one is refused.</summary>
[TwinCATFact]
public async Task String_write_round_trips_and_refuses_overflow()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
await using var drv = NewDriver("tc3-gate-u9-string", native: false,
new TwinCATTagDefinition("S", Host, "GVL_Primitives.vString", TwinCATDataType.String));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var original = (string)(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value!;
try
{
(await drv.WriteAsync([new WriteRequest("S", "Grüße 40°")], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(0u);
(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe("Grüße 40°");
(await drv.WriteAsync([new WriteRequest("S", new string('x', 81))], TestContext.Current.CancellationToken))[0]
.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange, "vString is STRING(80)");
}
finally
{
await drv.WriteAsync([new WriteRequest("S", original)], CancellationToken.None);
}
(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(original);
}
}
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -56,10 +57,22 @@ internal class FakeTwinCATClient : ITwinCATClient
{ {
ConnectCount++; ConnectCount++;
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException(); if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
if (BlockConnect is { } block)
{
// Models the real client's blocking SDK Connect: ignores the token until released.
return block.Task.ContinueWith(_ => { IsConnected = true; }, TaskScheduler.Default);
}
IsConnected = true; IsConnected = true;
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <summary>When set, <see cref="ConnectAsync"/> does not complete (and ignores cancellation) until this
/// is completed — a probe wedged in the synchronous ADS <c>Connect</c>.</summary>
public TaskCompletionSource? BlockConnect { get; set; }
/// <summary>Gets or sets the simulated notification-queue drop count.</summary>
public long DroppedNotificationCount { get; set; }
/// <summary>Simulates reading a value from a symbol.</summary> /// <summary>Simulates reading a value from a symbol.</summary>
/// <param name="symbolPath">The path to the symbol to read.</param> /// <param name="symbolPath">The path to the symbol to read.</param>
/// <param name="type">The data type of the symbol.</param> /// <param name="type">The data type of the symbol.</param>
@@ -70,16 +83,31 @@ internal class FakeTwinCATClient : ITwinCATClient
public virtual Task<(object? value, uint status)> ReadValueAsync( public virtual Task<(object? value, uint status)> ReadValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct) string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct)
{ {
if (BlockRead is { } block)
{
// A read wedged in the (uncancellable) wire call; completes with the normal answer once released.
return block.Task.ContinueWith(_ =>
{
BlockRead = null;
return ReadValueAsync(symbolPath, type, bitIndex, arrayCount, CancellationToken.None).Result;
}, TaskScheduler.Default);
}
if (ThrowOnRead) throw Exception ?? new InvalidOperationException(); if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
SingleReadCount++; Interlocked.Increment(ref _singleReadCount);
LastReadArrayCount = arrayCount; LastReadArrayCount = arrayCount;
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good; var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
var value = Values.TryGetValue(symbolPath, out var v) ? v : null; var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
return Task.FromResult((value, status)); return Task.FromResult((value, status));
} }
/// <summary>When set, <see cref="ReadValueAsync"/> does not complete (and ignores cancellation) until this is
/// completed — a poll read wedged in the wire call.</summary>
public TaskCompletionSource? BlockRead { get; set; }
private int _singleReadCount;
/// <summary>Gets the number of individual <see cref="ReadValueAsync"/> calls served.</summary> /// <summary>Gets the number of individual <see cref="ReadValueAsync"/> calls served.</summary>
public int SingleReadCount { get; private set; } public int SingleReadCount { get => Volatile.Read(ref _singleReadCount); private set => Volatile.Write(ref _singleReadCount, value); }
/// <summary>Gets the size of each <see cref="ReadValuesAsync"/> batch the driver issued, in order (#645).</summary> /// <summary>Gets the size of each <see cref="ReadValuesAsync"/> batch the driver issued, in order (#645).</summary>
public List<int> SumReadBatches { get; } = new(); public List<int> SumReadBatches { get; } = new();
/// <summary>Gets or sets an exception <see cref="ReadValuesAsync"/> throws instead of answering — a /// <summary>Gets or sets an exception <see cref="ReadValuesAsync"/> throws instead of answering — a
@@ -130,21 +158,102 @@ internal class FakeTwinCATClient : ITwinCATClient
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct) string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
{ {
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException(); if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
if (type is TwinCATDataType.String or TwinCATDataType.WString
&& SymbolShapes.TryGetValue(symbolPath, out var shape))
{
// The real client's encode plan (resolve → CheckRead → codec at the PLC width).
var (planStatus, bytes) = AdsTwinCATClient.PlanStringWrite(type, shape, value, utf8: false);
if (planStatus != TwinCATStatusMapper.Good) return Task.FromResult(planStatus);
RawWrites[symbolPath] = bytes;
}
WriteLog.Add((symbolPath, type, bitIndex, value)); WriteLog.Add((symbolPath, type, bitIndex, value));
Values[symbolPath] = value; Values[symbolPath] = value;
var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good; var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
return Task.FromResult(status); return Task.FromResult(status);
} }
/// <summary>Gets the log of every <see cref="WriteArrayAsync"/> call.</summary>
public List<(string symbol, TwinCATDataType type, IReadOnlyList<object> elements)> ArrayWriteLog { get; } = new();
/// <summary>Simulates an array write: records it, stores the elements and answers from <see cref="WriteStatuses"/>.</summary>
/// <param name="symbolPath">The array symbol path.</param>
/// <param name="type">The declared element type.</param>
/// <param name="elements">The converted elements.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The write status.</returns>
public virtual Task<uint> WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList<object> elements,
CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
if (ArrayShapes.TryGetValue(symbolPath, out var array) && SymbolShapes.TryGetValue(symbolPath, out var shape))
{
// The real client's encode plan (CheckArray layout → TwinCATArrayCodec.Encode).
var (planStatus, bytes) = AdsTwinCATClient.PlanArrayWrite(type, elements, shape, array, utf8: false);
if (planStatus != TwinCATStatusMapper.Good) return Task.FromResult(planStatus);
RawWrites[symbolPath] = bytes;
}
ArrayWriteLog.Add((symbolPath, type, elements));
Values[symbolPath] = elements;
return Task.FromResult(WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good);
}
/// <summary>Resolved PLC symbol shapes; a string / array write to a symbol listed here runs the real client's
/// encode plan and lands its bytes in <see cref="RawWrites"/> (#739 fix round 1).</summary>
public Dictionary<string, TwinCATPlcSymbolShape> SymbolShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Resolved PLC array shapes for <see cref="SymbolShapes"/> entries that are arrays.</summary>
public Dictionary<string, TwinCATPlcArrayShape> ArrayShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>The raw bytes the encode plan produced, by symbol.</summary>
public ConcurrentDictionary<string, byte[]> RawWrites { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Raw bytes of bit-write parent words, by parent path (#739 H18). The byte count is the parent's width.</summary>
public Dictionary<string, byte[]> ParentWords { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Optional parent shapes; absent = an unsigned integer word as wide as its <see cref="ParentWords"/> entry.</summary>
public Dictionary<string, TwinCATPlcSymbolShape> ParentShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the log of every <see cref="WriteBitAsync"/> call.</summary>
public List<(string parent, int bit, bool value)> BitWriteLog { get; } = new();
/// <summary>When set, <see cref="WriteBitAsync"/> yields between its read and its write so a missing
/// per-parent lock in the driver loses updates.</summary>
public bool YieldInBitWrite { get; set; }
/// <summary>Simulates the parent-width bit RMW with the driver's own <see cref="TwinCATBitAccess.ApplyBitWrite"/>.</summary>
/// <param name="parentPath">The parent word's symbol path.</param>
/// <param name="bit">The bit index.</param>
/// <param name="value">The bit value.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The write status.</returns>
public virtual async Task<uint> WriteBitAsync(string parentPath, int bit, bool value, CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
lock (BitWriteLog) BitWriteLog.Add((parentPath, bit, value));
if (ReadStatuses.TryGetValue(parentPath, out var readStatus) && readStatus != TwinCATStatusMapper.Good)
return readStatus;
byte[] current;
lock (ParentWords) current = ParentWords.TryGetValue(parentPath, out var w) ? [.. w] : [];
var shape = ParentShapes.TryGetValue(parentPath, out var sh)
? sh
: new TwinCATPlcSymbolShape(TwinCATPlcKind.Scalar, current.Length == 0 ? 4 : current.Length, false, "UDINT");
if (YieldInBitWrite) await Task.Yield();
var status = TwinCATBitAccess.ApplyBitWrite(shape, current, bit, value, out var updated);
if (status != TwinCATStatusMapper.Good) return status;
lock (ParentWords) ParentWords[parentPath] = updated;
return WriteStatuses.TryGetValue(parentPath, out var s) ? s : TwinCATStatusMapper.Good;
}
/// <summary>Simulates probing the connection status.</summary> /// <summary>Simulates probing the connection status.</summary>
/// <param name="ct">The cancellation token.</param> /// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the probe result.</returns> /// <returns>A task that returns the probe result.</returns>
public virtual Task<bool> ProbeAsync(CancellationToken ct) public virtual Task<bool> ProbeAsync(CancellationToken ct)
{ {
Interlocked.Increment(ref _probeCount);
if (ThrowOnProbe) return Task.FromResult(false); if (ThrowOnProbe) return Task.FromResult(false);
return Task.FromResult(ProbeResult); return Task.FromResult(ProbeResult);
} }
private int _probeCount;
/// <summary>Gets how many times <see cref="ProbeAsync"/> ran — a measured window for absence assertions.</summary>
public int ProbeCount => Volatile.Read(ref _probeCount);
/// <summary>Releases unmanaged resources.</summary> /// <summary>Releases unmanaged resources.</summary>
public virtual void Dispose() public virtual void Dispose()
{ {
@@ -160,9 +269,19 @@ internal class FakeTwinCATClient : ITwinCATClient
public bool ThrowOnAddNotification { get; set; } public bool ThrowOnAddNotification { get; set; }
/// <summary>Per-symbol exceptions <see cref="AddNotificationAsync"/> throws (e.g. a /// <summary>Per-symbol exceptions <see cref="AddNotificationAsync"/> throws (e.g. a
/// <see cref="TwinCATTypeMismatchException"/> for one refused tag).</summary> /// <see cref="TwinCATTypeMismatchException"/> for one refused tag).</summary>
public Dictionary<string, Exception> AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase); public ConcurrentDictionary<string, Exception> AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, int> _addAttempts = new(StringComparer.OrdinalIgnoreCase);
/// <summary>How many times <see cref="AddNotificationAsync"/> was called for <paramref name="symbolPath"/>.</summary>
/// <param name="symbolPath">The symbol path.</param>
/// <returns>The attempt count.</returns>
public int AttemptsFor(string symbolPath) => _addAttempts.TryGetValue(symbolPath, out var n) ? n : 0;
/// <summary>Records the most recently-supplied <c>maxDelayMs</c> for Driver.TwinCAT-014 tests.</summary> /// <summary>Records the most recently-supplied <c>maxDelayMs</c> for Driver.TwinCAT-014 tests.</summary>
public int LastMaxDelayMs { get; private set; } public int LastMaxDelayMs { get; private set; }
/// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate BEFORE applying
/// <see cref="AddNotificationErrors"/> — wedges an attempt in flight whose outcome is a failure.</summary>
public TaskCompletionSource? PreAttemptGate { get; set; }
/// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate before creating the handle — /// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate before creating the handle —
/// lets a test wedge a replay/register mid-flight (STAB-17 orphan race).</summary> /// lets a test wedge a replay/register mid-flight (STAB-17 orphan race).</summary>
public TaskCompletionSource? AddNotificationGate { get; set; } public TaskCompletionSource? AddNotificationGate { get; set; }
@@ -181,6 +300,8 @@ internal class FakeTwinCATClient : ITwinCATClient
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime, string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime,
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken) int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
{ {
_addAttempts.AddOrUpdate(symbolPath, 1, (_, n) => n + 1);
if (PreAttemptGate is { } pre) await pre.Task.ConfigureAwait(false);
if (ThrowOnAddNotification) if (ThrowOnAddNotification)
throw Exception ?? new InvalidOperationException("fake AddNotification failure"); throw Exception ?? new InvalidOperationException("fake AddNotification failure");
if (AddNotificationErrors.TryGetValue(symbolPath, out var perSymbol)) if (AddNotificationErrors.TryGetValue(symbolPath, out var perSymbol))
@@ -0,0 +1,199 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 U9: array writes and STRING writes go through the same codecs the read side decodes with.
/// Arrays were advertised writable but every write threw InvalidCastException (reported BadTypeMismatch);
/// strings bypassed the codec (host-ANSI encoding, no length check against STRING(n)).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATArrayStringWriteTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
// ---- string codec: encode ----
/// <summary>A STRING(n) encodes NUL-terminated and NUL-padded to the PLC width, in Windows-1252.</summary>
[Fact]
public void EncodeString_is_NUL_padded_Windows1252_at_the_PLC_width()
{
TwinCATStringCodec.EncodeString("AB", 4, utf8: false).ShouldBe(new byte[] { 0x41, 0x42, 0, 0 });
TwinCATStringCodec.EncodeString("ä€", 3, utf8: false).ShouldBe(new byte[] { 0xE4, 0x80, 0 });
TwinCATStringCodec.EncodeString("ä", 3, utf8: true).ShouldBe(new byte[] { 0xC3, 0xA4, 0 });
TwinCATStringCodec.EncodeString("", 2, utf8: false).ShouldBe(new byte[] { 0, 0 });
}
/// <summary>The encode is the decode's inverse (a STRING(n) of exactly n characters fits).</summary>
[Fact]
public void EncodeString_round_trips_through_DecodeString()
{
foreach (var utf8 in new[] { false, true })
{
var bytes = TwinCATStringCodec.EncodeString("Grüße 40°", 81, utf8);
TwinCATStringCodec.DecodeString(bytes, utf8).ShouldBe("Grüße 40°");
}
TwinCATStringCodec.EncodeString("ABC", 4, utf8: false).Length.ShouldBe(4);
}
/// <summary>A value longer than STRING(n), or a character the target encoding cannot hold, is out of range.</summary>
[Fact]
public void EncodeString_refuses_a_value_that_does_not_fit()
{
Should.Throw<OverflowException>(() => TwinCATStringCodec.EncodeString("ABC", 3, utf8: false));
Should.Throw<OverflowException>(() => TwinCATStringCodec.EncodeString("中", 10, utf8: false));
Should.Throw<OverflowException>(() => TwinCATStringCodec.EncodeString("ää", 4, utf8: true)); // 4 bytes + NUL
}
/// <summary>A WSTRING(n) encodes UTF-16LE with a 2-byte terminator and round-trips.</summary>
[Fact]
public void EncodeWString_is_UTF16LE_with_a_terminator()
{
TwinCATStringCodec.EncodeWString("Hi", 6).ShouldBe(new byte[] { 0x48, 0, 0x69, 0, 0, 0 });
TwinCATStringCodec.DecodeWString(TwinCATStringCodec.EncodeWString("中文", 22)).ShouldBe("中文");
Should.Throw<OverflowException>(() => TwinCATStringCodec.EncodeWString("Hi", 4));
}
// ---- array codec: elements + encode ----
/// <summary>
/// A CLR list converts element-wise to the declared type (ScadaBridge writes an <c>int[]</c> to an
/// Int16 node); the element type is converted, not exact-matched.
/// </summary>
[Fact]
public void ToWriteElements_converts_each_element_to_the_declared_type()
{
var elements = TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 1, -2, 300 }, 3);
elements.ShouldBe(new object[] { (short)1, (short)-2, (short)300 });
TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Real, new List<object> { 1, 2.5 }, 2)
.ShouldBe(new object[] { 1f, 2.5f });
TwinCATArrayCodec.ToWriteElements(TwinCATDataType.String, new[] { "a", "b" }, 2)
.ShouldBe(new object[] { "a", "b" });
}
/// <summary>A length mismatch and an out-of-range element are out of range; a scalar is a type mismatch.</summary>
[Fact]
public void ToWriteElements_refuses_a_wrong_shape()
{
Should.Throw<OverflowException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 1, 2 }, 3));
Should.Throw<OverflowException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 70_000 }, 1));
Should.Throw<InvalidCastException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, 5, 1));
Should.Throw<InvalidCastException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.String, "abc", 3));
Should.Throw<InvalidCastException>(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, null, 1));
}
/// <summary>Encode lays elements out little-endian at the PLC stride — the exact inverse of Decode.</summary>
[Fact]
public void Encode_is_the_inverse_of_Decode()
{
var intLayout = new TwinCATArrayLayout(2, 2);
var intBytes = TwinCATArrayCodec.Encode(TwinCATDataType.Int, [(short)1, (short)-2], intLayout, utf8: false);
intBytes.ShouldBe(new byte[] { 0x01, 0x00, 0xFE, 0xFF });
RoundTrip(TwinCATDataType.Bool, [true, false, true], new TwinCATArrayLayout(3, 1));
RoundTrip(TwinCATDataType.DInt, [int.MinValue, 7], new TwinCATArrayLayout(2, 4));
RoundTrip(TwinCATDataType.ULInt, [ulong.MaxValue], new TwinCATArrayLayout(1, 8));
RoundTrip(TwinCATDataType.LReal, [1.5, -2.25], new TwinCATArrayLayout(2, 8));
RoundTrip(TwinCATDataType.String, ["ab", "Grüße"], new TwinCATArrayLayout(2, 11));
RoundTrip(TwinCATDataType.WString, ["中", "x"], new TwinCATArrayLayout(2, 8));
// TIME elements arrive as the encoded uint ms (ConvertForWrite) and decode back to double ms.
var time = TwinCATArrayCodec.Encode(TwinCATDataType.Time,
TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Time, new[] { 1500.0, 20.0 }, 2), new TwinCATArrayLayout(2, 4), false);
TwinCATArrayCodec.Decode(TwinCATDataType.Time, time, new TwinCATArrayLayout(2, 4), false)
.ShouldBe(new[] { 1500.0, 20.0 });
}
/// <summary>A string element longer than the element's STRING(n) is out of range.</summary>
[Fact]
public void Encode_refuses_a_string_element_wider_than_its_stride()
{
Should.Throw<OverflowException>(() =>
TwinCATArrayCodec.Encode(TwinCATDataType.String, ["toolong"], new TwinCATArrayLayout(1, 4), utf8: false));
Should.Throw<ArgumentException>(() =>
TwinCATArrayCodec.Encode(TwinCATDataType.Int, [(short)1], new TwinCATArrayLayout(2, 2), utf8: false));
}
private static void RoundTrip(TwinCATDataType type, object[] elements, TwinCATArrayLayout layout)
{
var bytes = TwinCATArrayCodec.Encode(type, elements, layout, utf8: false);
bytes.Length.ShouldBe(layout.ByteLength);
var decoded = TwinCATArrayCodec.Decode(type, bytes, layout, utf8: false);
decoded.Cast<object>().ShouldBe(elements);
}
// ---- driver ----
private static (TwinCATDriver drv, FakeTwinCATClient fake) NewDriver(params TwinCATTagDefinition[] tags)
{
var fake = new FakeTwinCATClient();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", new FakeTwinCATClientFactory { Customise = () => fake });
return (drv, fake);
}
/// <summary>An array tag's write sends ArrayLength converted elements through WriteArrayAsync.</summary>
[Fact]
public async Task An_array_write_sends_converted_elements()
{
var (drv, fake) = NewDriver(new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 3));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.WriteAsync([new WriteRequest("Buf", new[] { 1, 2, 3 })], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
var write = fake.ArrayWriteLog.ShouldHaveSingleItem();
write.symbol.ShouldBe("MAIN.Buf");
write.elements.ShouldBe(new object[] { (short)1, (short)2, (short)3 });
fake.WriteLog.ShouldBeEmpty(); // never the scalar path (that threw InvalidCastException)
}
/// <summary>
/// A length mismatch is BadOutOfRange and a scalar value BadTypeMismatch — nothing reaches the wire
/// and health is untouched (a refusal is not a comms fault). Positive control: the good write above.
/// </summary>
[Fact]
public async Task A_wrong_shape_array_write_is_refused_before_the_wire()
{
var (drv, fake) = NewDriver(new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 3));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[
new WriteRequest("Buf", new[] { 1, 2 }),
new WriteRequest("Buf", 5),
new WriteRequest("Buf", new[] { 1, 2, 70_000 }),
], TestContext.Current.CancellationToken);
results.Select(r => r.StatusCode).ShouldBe(
[
TwinCATStatusMapper.BadOutOfRange,
TwinCATStatusMapper.BadTypeMismatch,
TwinCATStatusMapper.BadOutOfRange,
]);
fake.ArrayWriteLog.ShouldBeEmpty();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>An over-long string surfaced by the client as OverflowException is BadOutOfRange, not a comms fault.</summary>
[Fact]
public async Task An_over_long_string_write_is_BadOutOfRange()
{
var (drv, fake) = NewDriver(new TwinCATTagDefinition("Name", Host, "MAIN.Name", TwinCATDataType.String));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
fake.ThrowOnWrite = true;
fake.Exception = new OverflowException("6 bytes do not fit STRING(4)");
var result = await drv.WriteAsync([new WriteRequest("Name", "toolong")], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}
@@ -0,0 +1,89 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 / R02:P1 — TwinCAT is an <see cref="IBatchSubscribable"/>. DriverInstanceActor subscribes ONLY the
/// batch event of a batch-capable driver, so every data change — poll cycle AND native push, including the
/// Bad snapshots the native path publishes itself — must reach <c>OnDataChangeBatch</c>; the per-tag event
/// keeps firing for legacy consumers (the CLI).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATBatchSubscribeTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool native) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = native,
}, "drv-1", factory);
/// <summary>The driver advertises the batch capability.</summary>
[Fact]
public void Driver_is_batch_subscribable() =>
NewDriver(new FakeTwinCATClientFactory(), native: false).ShouldBeAssignableTo<IBatchSubscribable>();
/// <summary>A poll cycle that changes two tags raises ONE batch carrying both, and the per-tag event twice.</summary>
[Fact]
public async Task A_poll_cycle_raises_one_batch_with_every_changed_tag()
{
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 2 } },
};
var drv = NewDriver(factory, native: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
var single = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
drv.OnDataChange += (_, e) => single.Enqueue(e);
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
await WaitForAsync(() => !batches.IsEmpty, TimeSpan.FromSeconds(3));
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
var first = batches.First();
first.SubscriptionHandle.ShouldBe(handle);
first.Items.Select(i => i.FullReference).ShouldBe(["A", "B"], ignoreOrder: true);
single.Count(e => e.FullReference is "A" or "B").ShouldBeGreaterThanOrEqualTo(2);
}
/// <summary>A native push and the native path's own Bad snapshot both reach the batch event.</summary>
[Fact]
public async Task Native_pushes_and_native_bad_snapshots_reach_the_batch_event()
{
var fake = new FakeTwinCATClient();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, native: true);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
drv.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
_ = await drv.SubscribeAsync(["A", "Ghost"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
fake.FireNotification("MAIN.A", 42);
var items = batches.SelectMany(b => b.Items).ToList();
items.Single(i => i.FullReference == "Ghost").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
var a = items.Single(i => i.FullReference == "A");
a.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
a.Snapshot.Value.ShouldBe(42);
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}
@@ -55,4 +55,50 @@ public sealed class TwinCATBitAccessTests
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("ST_X", DataTypeCategory.Struct, 8)).ShouldNotBeNull(); TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("ST_X", DataTypeCategory.Struct, 8)).ShouldNotBeNull();
TwinCATBitAccess.CheckParent(31, TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4)).ShouldBeNull(); TwinCATBitAccess.CheckParent(31, TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4)).ShouldBeNull();
} }
// ---- #739 H18: the bit WRITE is a read-modify-write at the parent's own width ----
/// <summary>A bit write flips exactly one bit of the parent's raw bytes at the parent's width.</summary>
[Fact]
public void ApplyBitWrite_flips_one_bit_at_the_parent_width()
{
var word = TwinCATPlcSymbolShape.Classify("WORD", DataTypeCategory.Primitive, 2);
TwinCATBitAccess.ApplyBitWrite(word, [0x01, 0x00], 9, true, out var set).ShouldBe(TwinCATStatusMapper.Good);
set.ShouldBe(new byte[] { 0x01, 0x02 });
TwinCATBitAccess.ApplyBitWrite(word, [0xFF, 0xFF], 3, false, out var cleared).ShouldBe(TwinCATStatusMapper.Good);
cleared.ShouldBe(new byte[] { 0xF7, 0xFF });
}
/// <summary>
/// A signed parent with its sign bit set is plain bytes — the old UDInt round-trip read it back as
/// a value ≥ 2³¹ and a checked conversion turned ANY bit write into BadOutOfRange.
/// </summary>
[Fact]
public void ApplyBitWrite_on_a_negative_signed_parent_preserves_the_sign_bit()
{
var intWord = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2);
TwinCATBitAccess.ApplyBitWrite(intWord, [0x00, 0x80], 0, true, out var updated).ShouldBe(TwinCATStatusMapper.Good);
updated.ShouldBe(new byte[] { 0x01, 0x80 });
}
/// <summary>A bit beyond the parent is BadOutOfRange; a non-integer parent BadTypeMismatch; nothing is produced.</summary>
[Fact]
public void ApplyBitWrite_refuses_a_bit_the_parent_does_not_have()
{
var word = TwinCATPlcSymbolShape.Classify("UINT", DataTypeCategory.Primitive, 2);
TwinCATBitAccess.ApplyBitWrite(word, [0, 0], 16, true, out var none).ShouldBe(TwinCATStatusMapper.BadOutOfRange);
none.ShouldBeEmpty();
var real = TwinCATPlcSymbolShape.Classify("REAL", DataTypeCategory.Primitive, 4);
TwinCATBitAccess.ApplyBitWrite(real, [0, 0, 0, 0], 1, true, out _).ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
}
/// <summary>A parent read shorter than the parent's width is never written back (it would zero bytes).</summary>
[Fact]
public void ApplyBitWrite_refuses_a_short_parent_buffer()
{
var dword = TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4);
TwinCATBitAccess.ApplyBitWrite(dword, [0xFF, 0xFF], 1, true, out var none)
.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
none.ShouldBeEmpty();
}
} }
@@ -0,0 +1,26 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>Collects the driver's Warning-level messages (thread-safe) for once-per-X assertions.</summary>
internal sealed class TwinCATCapturingLogger : ILogger<TwinCATDriver>
{
private readonly List<string> _warnings = [];
/// <summary>Gets a snapshot of the formatted Warning messages logged so far.</summary>
public IReadOnlyList<string> Warnings { get { lock (_warnings) return [.. _warnings]; } }
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (logLevel == LogLevel.Warning) lock (_warnings) _warnings.Add(formatter(state, exception));
}
}
@@ -0,0 +1,163 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// The driver health contract (#739 H12): data proving the device answered — a native notification or
/// a successful probe — advances <c>LastSuccessfulRead</c>; sustained probe/connect failure degrades with
/// it preserved; a refusal (a write the client cannot express, a replay the PLC refuses) is no comms fault.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATHealthContractTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, TwinCATProbeOptions probe, bool native = true) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
Probe = probe,
UseNativeNotifications = native,
}, "drv-1", factory);
private static TwinCATProbeOptions FastProbe => new()
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(30),
Timeout = TimeSpan.FromMilliseconds(100),
};
/// <summary>A native push advances <c>LastSuccessfulRead</c> and restores Healthy after a comms-fault write.</summary>
[Fact]
public async Task A_native_notification_advances_LastSuccessfulRead_and_restores_Healthy()
{
var fake = new FakeTwinCATClient();
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false });
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead;
initStamp.ShouldNotBeNull();
// A write that fails on the wire degrades (comms fault) with the stamp preserved.
fake.ThrowOnWrite = true;
_ = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
drv.GetHealth().LastSuccessfulRead.ShouldBe(initStamp);
// Condition, not a fixed delay: the clock only has to move past the stamp (the Degraded state means
// the notification is not inside the Healthy throttle window).
await WaitForAsync(() => DateTime.UtcNow > initStamp!.Value, TimeSpan.FromSeconds(2));
fake.FireNotification("MAIN.Speed", 7);
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Healthy);
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp!.Value);
}
/// <summary>A successful probe advances <c>LastSuccessfulRead</c> in a driver that serves only notifications.</summary>
[Fact]
public async Task A_successful_probe_advances_LastSuccessfulRead()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, FastProbe);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
await WaitForAsync(() => drv.GetHealth().LastSuccessfulRead > initStamp.AddMilliseconds(250), TimeSpan.FromSeconds(3));
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp.AddMilliseconds(250));
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>
/// An unreachable AMS router (every probe connect throws) degrades the driver after
/// <see cref="TwinCATDriver.ProbeFailuresBeforeDegraded"/> consecutive failures, preserving the stamp —
/// before, the probe caught it and health stayed Healthy forever.
/// </summary>
[Fact]
public async Task Consecutive_probe_failures_degrade_preserving_LastSuccessfulRead()
{
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { ThrowOnConnect = true } };
var drv = NewDriver(factory, FastProbe);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead;
await WaitForAsync(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(3));
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Degraded);
health.LastSuccessfulRead.ShouldBe(initStamp);
factory.Clients.Count.ShouldBeGreaterThanOrEqualTo(TwinCATDriver.ProbeFailuresBeforeDegraded);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>
/// A write the client refuses with <see cref="NotSupportedException"/> is <c>BadNotSupported</c> with no
/// health write (#611: a refusal is not a comms fault). Positive control: a wire failure does degrade.
/// </summary>
[Fact]
public async Task A_NotSupported_write_leaves_health_alone()
{
var fake = new FakeTwinCATClient { ThrowOnWrite = true, Exception = new NotSupportedException("no") };
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false }, native: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotSupported);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
fake.Exception = new InvalidOperationException("wire");
result = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>
/// A reconnect replay the PLC REFUSES (an isolated type-mismatch tag re-attempted on every reconnect)
/// republishes the refusal's status and does not degrade. Positive control: a replay that fails on the
/// wire degrades (covered for the same flow in <c>TwinCATReconnectReplayTests</c>).
/// </summary>
[Fact]
public async Task A_refused_replay_republishes_the_refusal_status_without_degrading()
{
var build = 0;
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
if (++build >= 2)
c.AddNotificationErrors["MAIN.Speed"] = new TwinCATTypeMismatchException("MAIN.Speed", "now a STRING");
return c;
},
};
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false });
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
factory.Clients[0].SimulateWireDrop();
// A write reconnects (and replays) without itself touching health on success.
_ = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
factory.Clients.Count.ShouldBe(2);
events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}
@@ -0,0 +1,298 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 L8 + the Lane A runtime contract (final stop = <c>ShutdownAsync</c> then <c>DisposeAsync</c>,
/// bounded 5 s + 250 ms): a probe wedged in the blocking ADS connect cannot hang teardown; dispose may run
/// while a shutdown is still running; an abandoned initialise overlapping the next shutdown never installs
/// its generation; a value queued before unsubscribe is not delivered after it; re-init swaps its tables
/// and retracts the previous generation's notifications; queue drops are reported.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATLifecycleTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool probe = true, bool native = true,
TwinCATCapturingLogger? log = null, params TwinCATTagDefinition[] tags) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags.Length > 0
? tags
: [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
Probe = probe
? new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30), Timeout = TimeSpan.FromMilliseconds(100) }
: new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = native,
}, "drv-1", factory, log);
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(10);
}
/// <summary>
/// A probe wedged in the (uncancellable) connect no longer hangs ShutdownAsync — the join is bounded —
/// and the client the wedged connect eventually produces is disposed, not installed on a dead device.
/// </summary>
[Fact]
public async Task Shutdown_is_bounded_when_a_probe_is_wedged_in_connect()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
var drv = NewDriver(factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
factory.Clients.Count.ShouldBe(1); // the probe is inside the wedged connect
var sw = Stopwatch.StartNew();
await drv.ShutdownAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ShutdownBudget + TimeSpan.FromMilliseconds(500));
wedge.SetResult();
await WaitForAsync(() => factory.Clients[0].DisposeCount == 1, TimeSpan.FromSeconds(2));
factory.Clients[0].DisposeCount.ShouldBe(1);
drv.DeviceCount.ShouldBe(0);
}
/// <summary>
/// DisposeAsync while a ShutdownAsync is still running (joining a wedged probe) returns promptly and
/// both complete without throwing; the device is released exactly once.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_a_running_shutdown_completes_promptly()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
var drv = NewDriver(factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
var shutdown = drv.ShutdownAsync(CancellationToken.None);
var sw = Stopwatch.StartNew();
await drv.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
// Ordering is the real assertion: dispose finished while the first shutdown was still joining the
// wedged probe. The elapsed bound is generous (the runtime allows 250 ms; a loaded CI box gets 1 s).
shutdown.IsCompleted.ShouldBeFalse("the positive control: the first shutdown really was still joining the probe");
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
wedge.SetResult();
await shutdown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
drv.Dispose(); // and a late sync Dispose is a no-op
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>
/// An InitializeAsync overtaken by a ShutdownAsync before it commits must not install its devices or
/// start probe loops when it resumes. Positive control: a fresh initialise afterwards installs normally.
/// </summary>
[Fact]
public async Task An_initialise_overtaken_by_shutdown_before_commit_installs_nothing()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory);
var paused = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
drv.BeforeCommitForTest = async () => { paused.TrySetResult(); await resume.Task; };
var abandoned = drv.InitializeAsync("{}", CancellationToken.None);
await paused.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
resume.SetResult();
await Should.ThrowAsync<OperationCanceledException>(() => abandoned.WaitAsync(TimeSpan.FromSeconds(5)));
drv.DeviceCount.ShouldBe(0);
factory.Clients.ShouldBeEmpty();
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
// Positive control + measured window: a fresh generation's probe creates exactly one client and
// ticks several times; an overtaken-generation probe loop (same interval, started earlier) would have
// created a second client within that window.
drv.BeforeCommitForTest = null;
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.DeviceCount.ShouldBe(1);
await WaitForAsync(() => factory.Clients.Count >= 1 && factory.Clients[0].ProbeCount >= 4, TimeSpan.FromSeconds(3));
factory.Clients[0].ProbeCount.ShouldBeGreaterThanOrEqualTo(4);
factory.Clients.Count.ShouldBe(1, "no probe loop of the overtaken generation may be running");
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>
/// The Lane A shape: an InitializeAsync the runtime abandoned while it is still releasing the previous
/// generation (joining a probe wedged in connect) overlaps the next ShutdownAsync. Shutdown stays
/// bounded and, once everything settles, no device, probe loop or live client survives.
/// </summary>
[Fact]
public async Task An_abandoned_initialise_overlapping_shutdown_leaves_nothing_running()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var first = true;
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient { BlockConnect = first ? wedge : null };
first = false;
return c;
},
};
var drv = NewDriver(factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // joins the wedged generation-1 probe
await WaitForAsync(() => drv.DeviceCount == 1 && factory.Clients.Count >= 2, TimeSpan.FromSeconds(3)); // gen 2 committed + probing
var sw = Stopwatch.StartNew();
await drv.ShutdownAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ShutdownBudget + TimeSpan.FromMilliseconds(500));
wedge.SetResult();
try { await abandoned.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); }
catch (OperationCanceledException) when (!TestContext.Current.CancellationToken.IsCancellationRequested) { }
drv.DeviceCount.ShouldBe(0);
await WaitForAsync(() => factory.Clients.All(c => c.DisposeCount > 0 || !c.IsConnected), TimeSpan.FromSeconds(2));
var settled = factory.Clients.Count;
// Measured window: a sibling driver on the same probe interval ticks 4 times; any surviving probe
// loop of the torn-down driver would have built a new client in that time.
var witnessFactory = new FakeTwinCATClientFactory();
var witness = NewDriver(witnessFactory);
await witness.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => witnessFactory.Clients.Count >= 1 && witnessFactory.Clients[0].ProbeCount >= 4, TimeSpan.FromSeconds(3));
witnessFactory.Clients[0].ProbeCount.ShouldBeGreaterThanOrEqualTo(4);
await witness.ShutdownAsync(TestContext.Current.CancellationToken);
factory.Clients.Count.ShouldBe(settled, "no probe loop may still be creating clients");
factory.Clients.ShouldAllBe(c => c.DisposeCount > 0 || !c.IsConnected);
}
/// <summary>
/// A value the client queued before the unsubscribe is dropped if dispatched after it.
/// Positive control: the same callback delivers while subscribed.
/// </summary>
[Fact]
public async Task A_notification_dispatched_after_unsubscribe_is_dropped()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, probe: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var callback = factory.Clients[0].Notifications.Single().OnChange; // what the client's queue holds
callback("MAIN.Speed", 1);
events.Count.ShouldBe(1);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
callback("MAIN.Speed", 2);
events.Count.ShouldBe(1);
}
/// <summary>
/// A direct re-initialise with a changed config swaps the tag table (the removed tag is unknown, the
/// added one resolves) and retracts the previous generation's live notifications.
/// </summary>
[Fact]
public async Task Reinitialise_swaps_tables_and_retracts_the_previous_generations_notifications()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, probe: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
factory.Clients[0].Notifications.Count.ShouldBe(1);
var next = System.Text.Json.JsonSerializer.Serialize(new
{
devices = new[] { new { hostAddress = Host } },
rawTags = new[]
{
new
{
rawPath = "Torque",
tagConfig = TwinCATTagDefinitionFactory.ToTagConfig(
new TwinCATTagDefinition("Torque", Host, "MAIN.Torque", TwinCATDataType.DInt)),
writeIdempotent = false,
deviceName = Host,
},
},
probe = new { enabled = false },
});
await drv.InitializeAsync(next, TestContext.Current.CancellationToken);
factory.Clients[0].Notifications.ShouldBeEmpty();
factory.Clients[0].DisposeCount.ShouldBe(1);
(await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken)).Single().StatusCode
.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
(await drv.ReadAsync(["Torque"], TestContext.Current.CancellationToken)).Single().StatusCode
.ShouldBe(TwinCATStatusMapper.Good);
}
/// <summary>Reads racing a stream of re-initialises never throw (no table is ever mid-Clear).</summary>
[Fact]
public async Task Reads_racing_reinitialise_never_throw()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewDriver(factory, probe: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Run for at least 300 ms of overlap AND until the reader has seen a Good read mid-churn (positive
// control), bounded at 10 s — a condition, not a fixed window, so a loaded machine cannot starve it.
var good = 0;
var reads = 0;
var inits = 0;
var minUntil = DateTime.UtcNow.AddMilliseconds(300);
var deadline = DateTime.UtcNow.AddSeconds(10);
bool Done() => (DateTime.UtcNow > minUntil && Volatile.Read(ref good) > 0 && Volatile.Read(ref inits) > 10)
|| DateTime.UtcNow > deadline;
var reader = Task.Run(async () =>
{
while (!Done())
{
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
Interlocked.Increment(ref reads);
if (r[0].StatusCode == TwinCATStatusMapper.Good) Interlocked.Increment(ref good);
}
}, TestContext.Current.CancellationToken);
while (!Done())
{
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
Interlocked.Increment(ref inits);
await Task.Yield(); // let the reader in between generations
}
await reader; // throws if any read threw
good.ShouldBeGreaterThan(0);
inits.ShouldBeGreaterThan(10);
}
/// <summary>Notification-queue drops are reported once per probe tick that sees new drops.</summary>
[Fact]
public async Task Notification_queue_drops_are_reported()
{
var fake = new FakeTwinCATClient();
var log = new TwinCATCapturingLogger();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, log: log);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => fake.ProbeCount >= 3, TimeSpan.FromSeconds(3));
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(0); // 3 ticks, no drops, no warning
fake.DroppedNotificationCount = 5;
await WaitForAsync(() => log.Warnings.Any(w => w.Contains("dropped 5")), TimeSpan.FromSeconds(2));
var ticks = fake.ProbeCount;
await WaitForAsync(() => fake.ProbeCount >= ticks + 3, TimeSpan.FromSeconds(3));
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(1); // 3 more ticks, no new drops, no repeat
fake.DroppedNotificationCount = 8;
await WaitForAsync(() => log.Warnings.Any(w => w.Contains("dropped 3")), TimeSpan.FromSeconds(2));
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(2);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
}
@@ -0,0 +1,225 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Native-subscribe isolation (#739): an unresolvable reference publishes <c>BadNodeIdUnknown</c>
/// (U12) instead of keeping whatever status the server seeded, and one failing registration does not
/// take down the rest of the subscription (H11).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATNativeIsolationTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATDriver NewNativeDriver(FakeTwinCATClientFactory factory, params TwinCATTagDefinition[] tags) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
/// <summary>
/// U12: an unknown reference and a tag routed to an unconfigured device each publish one
/// <c>BadNodeIdUnknown</c> snapshot; the resolvable tag still registers and delivers Good (positive control).
/// </summary>
[Fact]
public async Task Native_subscribe_publishes_BadNodeIdUnknown_for_unresolvable_references()
{
var factory = new FakeTwinCATClientFactory();
var drv = NewNativeDriver(factory,
new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt),
new TwinCATTagDefinition("Orphan", "ads://9.9.9.9.1.1:851", "MAIN.Orphan", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Ghost", "Speed", "Orphan"], TimeSpan.FromMilliseconds(100),
TestContext.Current.CancellationToken);
var ghost = events.Single(e => e.FullReference == "Ghost");
ghost.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
ghost.Snapshot.Value.ShouldBeNull();
events.Single(e => e.FullReference == "Orphan").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
// Positive control: the resolvable tag registered and its push arrives Good.
factory.Clients.ShouldHaveSingleItem().Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.Speed");
factory.Clients[0].FireNotification("MAIN.Speed", 42);
var good = events.Single(e => e.FullReference == "Speed");
good.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
good.Snapshot.Value.ShouldBe(42);
}
/// <summary>
/// H11: one tag whose AddDeviceNotification round-trip fails (not a refusal) goes Bad on its own and
/// the subscribe call SUCCEEDS — before, the exception disposed every registration and rethrew, so one
/// bad tag served zero data for the whole driver. The good tags keep delivering (positive control).
/// </summary>
[Fact]
public async Task One_failing_registration_does_not_tear_down_the_subscription()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.B"] = new InvalidOperationException("AddDeviceNotificationEx blew up");
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = NewNativeDriver(factory,
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt),
new TwinCATTagDefinition("C", Host, "MAIN.C", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["A", "B", "C"], TimeSpan.FromMilliseconds(100),
TestContext.Current.CancellationToken);
fake.Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.C"], ignoreOrder: true);
events.ShouldHaveSingleItem().FullReference.ShouldBe("B");
events.Single().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
fake.FireNotification("MAIN.C", 3);
events.Last().FullReference.ShouldBe("C");
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
// The failed tag is kept as a dead intent and unsubscribe still removes everything.
drv.GetDeviceState(Host)!.NativeRegistrations.Count.ShouldBe(3);
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
drv.GetDeviceState(Host)!.NativeRegistrations.ShouldBeEmpty();
fake.Notifications.ShouldBeEmpty();
}
/// <summary>
/// H11: a refusal (symbol not found — the device answered) isolates the tag with the refusal's own
/// status and leaves driver health alone; the good tag delivers.
/// </summary>
[Fact]
public async Task A_refused_symbol_is_isolated_without_a_health_write()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.Typo"] = new TwinCATNotificationRefusedException(
"MAIN.Typo", "resolving the symbol failed", TwinCATStatusMapper.BadNodeIdUnknown);
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = NewNativeDriver(factory,
new TwinCATTagDefinition("Typo", Host, "MAIN.Typo", TwinCATDataType.DInt),
new TwinCATTagDefinition("Good", Host, "MAIN.Good", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Typo", "Good"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
fake.FireNotification("MAIN.Good", 1);
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
}
/// <summary>
/// H11: in a two-device driver an unreachable device only blacks out ITS tags (Bad + dead intents,
/// health Degraded); the reachable device's tags register and deliver.
/// </summary>
[Fact]
public async Task An_unreachable_device_blacks_out_only_its_own_tags()
{
const string hostB = "ads://5.23.91.24.1.1:851";
var build = 0;
var factory = new FakeTwinCATClientFactory
{
// Device A's client is created first and works; device B's connect throws.
Customise = () => new FakeTwinCATClient { ThrowOnConnect = ++build == 2 },
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host), new TwinCATDeviceOptions(hostB)],
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("A1", Host, "MAIN.A1", TwinCATDataType.DInt),
new TwinCATTagDefinition("B1", hostB, "MAIN.B1", TwinCATDataType.DInt),
new TwinCATTagDefinition("B2", hostB, "MAIN.B2", TwinCATDataType.DInt),
new TwinCATTagDefinition("A2", Host, "MAIN.A2", TwinCATDataType.DInt),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["A1", "B1", "B2", "A2"], TimeSpan.FromMilliseconds(100),
TestContext.Current.CancellationToken);
factory.Clients.Count.ShouldBe(2); // B2 did not attempt a second connect
factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A1", "MAIN.A2"], ignoreOrder: true);
events.Select(e => e.FullReference).ShouldBe(["B1", "B2"], ignoreOrder: true);
events.ShouldAllBe(e => e.Snapshot.StatusCode == TwinCATStatusMapper.BadCommunicationError);
drv.GetDeviceState(hostB)!.NativeRegistrations.Count.ShouldBe(2);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
factory.Clients[0].FireNotification("MAIN.A2", 22);
events.Last().FullReference.ShouldBe("A2");
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
}
/// <summary>
/// H11: the dead intent left by a failed registration is re-registered by the probe-tick retry, and
/// its first push then arrives Good — the subscription heals without a re-subscribe.
/// </summary>
[Fact]
public async Task A_failed_registration_is_recovered_by_the_probe_tick_retry()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.B"] = new InvalidOperationException("transient");
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(40),
Timeout = TimeSpan.FromMilliseconds(100),
},
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
fake.AddNotificationErrors.Clear();
await WaitForAsync(() => fake.Notifications.Count == 1, TimeSpan.FromSeconds(3));
fake.Notifications.Count.ShouldBe(1);
fake.FireNotification("MAIN.B", 5);
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>A comms-class registration status stays an exception; any other status is a per-tag refusal.</summary>
[Fact]
public void Registration_failures_are_classified_by_status()
{
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadNodeIdUnknown)
.ShouldBeOfType<TwinCATNotificationRefusedException>().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadOutOfService)
.ShouldBeOfType<TwinCATNotificationRefusedException>();
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadCommunicationError)
.ShouldBeOfType<InvalidOperationException>();
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadTimeout)
.ShouldBeOfType<InvalidOperationException>();
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}
@@ -98,13 +98,14 @@ public sealed class TwinCATNativeNotificationTests
events.Count.ShouldBe(afterUnsub); events.Count.ShouldBe(afterUnsub);
} }
/// <summary>Verifies that native subscribe failure mid-registration cleans up partial state.</summary> /// <summary>
/// A registration failing mid-subscribe isolates that one tag (#739 H11): the first registration
/// stays live, the second is kept as a dead intent and goes Bad, and the call does not throw.
/// </summary>
[Fact] [Fact]
public async Task Native_subscribe_failure_mid_registration_cleans_up_partial_state() public async Task Native_subscribe_failure_mid_registration_isolates_the_failed_tag()
{ {
// Fail-on-second-call fake — first AddNotificationAsync succeeds, second throws. // Fail-on-second-call fake — first AddNotificationAsync succeeds, second throws.
// Subscribe's catch block must tear the first one down before rethrowing so no zombie
// notification lingers.
var fake = new FailAfterNAddsFake(new AbTagParamsIrrelevant(), succeedBefore: 1); var fake = new FailAfterNAddsFake(new AbTagParamsIrrelevant(), succeedBefore: 1);
var factory = new FakeTwinCATClientFactory { Customise = () => fake }; var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = new TwinCATDriver(new TwinCATDriverOptions var drv = new TwinCATDriver(new TwinCATDriverOptions
@@ -120,12 +121,19 @@ public sealed class TwinCATNativeNotificationTests
}, "drv-1", factory); }, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
await Should.ThrowAsync<InvalidOperationException>(() => var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None)); drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
// First registration succeeded then got torn down by the catch; second threw.
fake.AddCallCount.ShouldBe(2); fake.AddCallCount.ShouldBe(2);
fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up fake.Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.A");
var bad = events.ShouldHaveSingleItem();
bad.FullReference.ShouldBe("B");
bad.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
fake.Notifications.ShouldBeEmpty();
} }
/// <summary> /// <summary>
@@ -0,0 +1,104 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 P10: a bad tag warns ONCE (not once per poll — 50 typo'd symbols on a 500 ms poll used to be 100
/// Warnings/s forever), re-arms when it reads Good, and a device refusal leaves driver health alone while a
/// comms-class status degrades.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATReadWarningTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static (TwinCATDriver drv, FakeTwinCATClient fake, TwinCATCapturingLogger log) NewDriver()
{
var fake = new FakeTwinCATClient();
var log = new TwinCATCapturingLogger();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("Typo", Host, "MAIN.Typo", TwinCATDataType.DInt),
new TwinCATTagDefinition("Other", Host, "MAIN.Other", TwinCATDataType.DInt),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", new FakeTwinCATClientFactory { Customise = () => fake }, log);
return (drv, fake, log);
}
private static int WarningsFor(TwinCATCapturingLogger log, string reference) =>
log.Warnings.Count(w => w.Contains($"'{reference}'", StringComparison.Ordinal));
/// <summary>Repeated bad reads of one tag log one Warning; a second bad tag gets its own (positive control).</summary>
[Fact]
public async Task A_bad_tag_warns_once_across_polls_and_rearms_after_Good()
{
var (drv, fake, log) = NewDriver();
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
for (var i = 0; i < 5; i++)
(await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken))
.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
WarningsFor(log, "Typo").ShouldBe(1);
fake.ReadStatuses["MAIN.Other"] = TwinCATStatusMapper.BadNodeIdUnknown;
_ = await drv.ReadAsync(["Other"], TestContext.Current.CancellationToken);
WarningsFor(log, "Other").ShouldBe(1);
// Fixed, then broken again: a new episode warns again.
fake.ReadStatuses.Remove("MAIN.Typo");
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
WarningsFor(log, "Typo").ShouldBe(2);
}
/// <summary>A re-initialise re-arms the warning.</summary>
[Fact]
public async Task A_reinitialise_rearms_the_warning()
{
var (drv, fake, log) = NewDriver();
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
WarningsFor(log, "Typo").ShouldBe(2);
}
/// <summary>
/// A status proving the device answered (symbol not found) keeps the driver Healthy and advances the
/// stamp; a comms-class status (timeout) degrades with the stamp preserved.
/// </summary>
[Fact]
public async Task A_refusal_status_does_not_degrade_but_a_comms_status_does()
{
var (drv, fake, _) = NewDriver();
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
var deadline = DateTime.UtcNow.AddSeconds(2);
while (DateTime.UtcNow <= initStamp && DateTime.UtcNow < deadline) await Task.Delay(5, TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
var afterRefusal = drv.GetHealth();
afterRefusal.State.ShouldBe(DriverState.Healthy);
afterRefusal.LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp);
fake.ReadStatuses["MAIN.Other"] = TwinCATStatusMapper.BadTimeout;
_ = await drv.ReadAsync(["Other"], TestContext.Current.CancellationToken);
var afterTimeout = drv.GetHealth();
afterTimeout.State.ShouldBe(DriverState.Degraded);
afterTimeout.LastSuccessfulRead.ShouldBe(afterRefusal.LastSuccessfulRead);
}
}
@@ -296,23 +296,23 @@ public sealed class TwinCATReadWriteTests
factory.Clients[0].DisposeCount.ShouldBe(1); factory.Clients[0].DisposeCount.ShouldBe(1);
} }
// ---- BOOL-within-word RMW writes ---- // ---- BOOL-within-word RMW writes (#739 H18: at the parent's own width, on raw bytes) ----
/// <summary>A BOOL-within-word set reads the parent word, ORs the bit, writes it back as UDInt.</summary> /// <summary>A BOOL-within-word set read-modify-writes the parent word's raw bytes through WriteBitAsync.</summary>
[Fact] [Fact]
public async Task Bit_set_RMWs_parent_word_as_UDInt() public async Task Bit_set_RMWs_the_parent_word_bytes()
{ {
var (drv, factory) = NewDriver( var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool)); new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0b0001u } }; factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0b0001, 0, 0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None); var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good); results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
factory.Clients[0].Values["MAIN.Flags"].ShouldBe(0b1001u); factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0b1001, 0, 0, 0 });
factory.Clients[0].WriteLog.ShouldContain(e => factory.Clients[0].BitWriteLog.ShouldBe([("MAIN.Flags", 3, true)]);
e.symbol == "MAIN.Flags" && e.type == TwinCATDataType.UDInt && e.bit == null); factory.Clients[0].WriteLog.ShouldBeEmpty(); // no 32-bit UDInt round-trip any more
} }
/// <summary>A BOOL-within-word clear preserves the other bits in the parent word.</summary> /// <summary>A BOOL-within-word clear preserves the other bits in the parent word.</summary>
@@ -321,12 +321,12 @@ public sealed class TwinCATReadWriteTests
{ {
var (drv, factory) = NewDriver( var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool)); new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0xFFFFu } }; factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0xFF, 0xFF] } };
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None);
factory.Clients[0].Values["MAIN.Flags"].ShouldBe(0xFFF7u); factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xF7, 0xFF });
} }
/// <summary>RMW works on a DWORD parent (bit 20 set above the 16-bit boundary).</summary> /// <summary>RMW works on a DWORD parent (bit 20 set above the 16-bit boundary).</summary>
@@ -335,12 +335,31 @@ public sealed class TwinCATReadWriteTests
{ {
var (drv, factory) = NewDriver( var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Hi", "ads://5.23.91.23.1.1:851", "GVL.Status.20", TwinCATDataType.Bool)); new TwinCATTagDefinition("Hi", "ads://5.23.91.23.1.1:851", "GVL.Status.20", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.Status"] = 0u } }; factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["GVL.Status"] = [0, 0, 0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Hi", true)], CancellationToken.None); await drv.WriteAsync([new WriteRequest("Hi", true)], CancellationToken.None);
factory.Clients[0].Values["GVL.Status"].ShouldBe(1u << 20); BitConverter.ToUInt32(factory.Clients[0].ParentWords["GVL.Status"]).ShouldBe(1u << 20);
}
/// <summary>
/// A bit beyond a 16-bit parent is refused BadOutOfRange and nothing is written — before, the hardcoded
/// 32-bit RMW wrote into the neighbouring bytes. No health write: a refusal is not a comms fault.
/// </summary>
[Fact]
public async Task Bit_beyond_a_16_bit_parent_is_refused_without_writing()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Hi", "ads://5.23.91.23.1.1:851", "MAIN.Word.20", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Word"] = [0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Hi", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
factory.Clients[0].ParentWords["MAIN.Word"].ShouldBe(new byte[] { 0, 0 });
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
} }
/// <summary>A failed parent read short-circuits the RMW and surfaces the read status.</summary> /// <summary>A failed parent read short-circuits the RMW and surfaces the read status.</summary>
@@ -351,6 +370,7 @@ public sealed class TwinCATReadWriteTests
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool)); new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient factory.Customise = () => new FakeTwinCATClient
{ {
ParentWords = { ["MAIN.Flags"] = [0xAA, 0] },
ReadStatuses = { ["MAIN.Flags"] = TwinCATStatusMapper.BadNodeIdUnknown }, ReadStatuses = { ["MAIN.Flags"] = TwinCATStatusMapper.BadNodeIdUnknown },
}; };
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
@@ -358,25 +378,24 @@ public sealed class TwinCATReadWriteTests
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None); var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown); results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
factory.Clients[0].WriteLog.ShouldBeEmpty(); factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xAA, 0 });
} }
/// <summary>A Good parent read that yields a null value must NOT zero the word — it surfaces /// <summary>A Good parent read that yields no bytes must NOT zero the word — it surfaces a Bad status
/// a Bad status and writes nothing (treating null as 0 would clear every set bit).</summary> /// and writes nothing (treating it as 0 would clear every set bit).</summary>
[Fact] [Fact]
public async Task Bit_write_with_null_parent_value_does_not_zero_the_word() public async Task Bit_write_with_no_parent_bytes_does_not_zero_the_word()
{ {
var (drv, factory) = NewDriver( var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool)); new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
// Do NOT seed Values["MAIN.Flags"] — the fake returns (null, Good) for the parent read. // Do NOT seed ParentWords["MAIN.Flags"] — the fake answers the parent read Good with no bytes.
factory.Customise = () => new FakeTwinCATClient(); factory.Customise = () => new FakeTwinCATClient();
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None); var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError); results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
// No parent write attempted — the word is left untouched on the device. factory.Clients[0].ParentWords.ShouldNotContainKey("MAIN.Flags");
factory.Clients[0].WriteLog.ShouldBeEmpty();
} }
/// <summary>Concurrent bit writes to the same word compose correctly (per-parent lock).</summary> /// <summary>Concurrent bit writes to the same word compose correctly (per-parent lock).</summary>
@@ -387,12 +406,12 @@ public sealed class TwinCATReadWriteTests
.Select(b => new TwinCATTagDefinition($"Bit{b}", "ads://5.23.91.23.1.1:851", $"MAIN.Flags.{b}", TwinCATDataType.Bool)) .Select(b => new TwinCATTagDefinition($"Bit{b}", "ads://5.23.91.23.1.1:851", $"MAIN.Flags.{b}", TwinCATDataType.Bool))
.ToArray(); .ToArray();
var (drv, factory) = NewDriver(tags); var (drv, factory) = NewDriver(tags);
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0u } }; factory.Customise = () => new FakeTwinCATClient { YieldInBitWrite = true, ParentWords = { ["MAIN.Flags"] = [0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None); await drv.InitializeAsync("{}", CancellationToken.None);
await Task.WhenAll(Enumerable.Range(0, 8).Select(b => await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None))); drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
Convert.ToUInt32(factory.Clients[0].Values["MAIN.Flags"]).ShouldBe(0xFFu); factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xFF, 0 });
} }
} }
@@ -0,0 +1,302 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Shouldly;
using TwinCAT.TypeSystem;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 review fix round 1: the PLC-not-in-RUN health ruling, refused-registration retry backoff,
/// per-device worst-of health, RMW gates surviving teardown, a cancellable shutdown, the real
/// STRING / array encode path through the client planning helpers, and REAL range checks.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATReviewFixRound1Tests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private const string HostB = "ads://5.23.91.24.1.1:851";
private static TwinCATProbeOptions FastProbe => new()
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(30),
Timeout = TimeSpan.FromMilliseconds(100),
};
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, TwinCATProbeOptions probe, bool native,
params TwinCATTagDefinition[] tags) =>
new(new TwinCATDriverOptions
{
Devices = [.. tags.Select(t => t.DeviceHostAddress).Append(Host).Distinct().Select(h => new TwinCATDeviceOptions(h))],
RawTags = TwinCATRawTags.Entries(tags),
Probe = probe,
UseNativeNotifications = native,
}, "drv-1", factory);
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(10);
}
// ---- IMPORTANT: a PLC not in RUN leaves the driver Degraded ----
/// <summary>
/// A probe that answers with ADS state Stop writes Degraded "PLC in Stop" and still advances
/// LastSuccessfulRead (the device answered); back in Run the driver is Healthy (positive control).
/// </summary>
[Fact]
public async Task A_PLC_not_in_Run_is_Degraded_and_Run_restores_Healthy()
{
var fake = new FakeTwinCATClient { AdsStateText = "Stop" };
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: false,
new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
await WaitForAsync(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(3));
var stopped = drv.GetHealth();
stopped.State.ShouldBe(DriverState.Degraded);
stopped.LastError.ShouldNotBeNull().ShouldContain("PLC in Stop");
await WaitForAsync(() => drv.GetHealth().LastSuccessfulRead > initStamp, TimeSpan.FromSeconds(3));
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp);
// A Good read while the PLC is stopped must not paper over it.
_ = await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
fake.AdsStateText = "Run";
await WaitForAsync(() => drv.GetHealth().State == DriverState.Healthy, TimeSpan.FromSeconds(3));
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
// ---- MINOR 1: refused dead registrations back off; a class change republishes ----
/// <summary>
/// A REFUSED dead registration is not re-attempted every probe tick; a comms-failed one is (positive
/// control: it recovers). A symbol-version-changed signal re-arms the refused one at once.
/// </summary>
[Fact]
public async Task Refused_registrations_back_off_while_comms_failures_retry_each_tick()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.T"] = new TwinCATTypeMismatchException("MAIN.T", "declared DInt, PLC STRING");
fake.AddNotificationErrors["MAIN.C"] = new InvalidOperationException("transient");
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt),
new TwinCATTagDefinition("C", Host, "MAIN.C", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["T", "C"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
await WaitForAsync(() => fake.AttemptsFor("MAIN.C") >= 3, TimeSpan.FromSeconds(3));
fake.AttemptsFor("MAIN.C").ShouldBeGreaterThanOrEqualTo(3); // comms class: every tick
fake.AttemptsFor("MAIN.T").ShouldBe(1); // refusal: backed off
fake.AddNotificationErrors.TryRemove("MAIN.C", out _);
await WaitForAsync(() => fake.Notifications.Any(n => n.SymbolPath == "MAIN.C"), TimeSpan.FromSeconds(3));
fake.Notifications.ShouldContain(n => n.SymbolPath == "MAIN.C");
fake.FireSymbolVersionChanged();
await WaitForAsync(() => fake.AttemptsFor("MAIN.T") >= 2, TimeSpan.FromSeconds(3));
fake.AttemptsFor("MAIN.T").ShouldBe(2);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>When a retry fails with a different failure class the tag's status is republished, once.</summary>
[Fact]
public async Task A_retry_whose_failure_class_changes_republishes_the_status()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.X"] = new InvalidOperationException("router hiccup");
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
new TwinCATTagDefinition("X", Host, "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
await WaitForAsync(() => fake.AttemptsFor("MAIN.X") >= 3, TimeSpan.FromSeconds(3));
events.Count.ShouldBe(1, "a repeat of the same failure class publishes nothing");
fake.AddNotificationErrors["MAIN.X"] = new TwinCATNotificationRefusedException(
"MAIN.X", "symbol gone", TwinCATStatusMapper.BadNodeIdUnknown);
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(3));
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
// ---- MINOR 2: per-device worst-of health ----
/// <summary>
/// A comms fault on device B is not cleared by device A answering; B answering clears it
/// (positive control).
/// </summary>
[Fact]
public async Task One_devices_answer_does_not_clear_another_devices_comms_fault()
{
var clients = new ConcurrentDictionary<int, FakeTwinCATClient>();
var build = 0;
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
var c = new FakeTwinCATClient();
clients[Interlocked.Increment(ref build)] = c;
return c;
},
};
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", HostB, "MAIN.B", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); // client 1 = device A
_ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken); // client 2 = device B
clients[2].ReadStatuses["MAIN.B"] = TwinCATStatusMapper.BadTimeout;
_ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
_ = await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "device A answering says nothing about device B");
clients[2].ReadStatuses.Remove("MAIN.B");
_ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
// ---- MINOR 3: RMW gates are kept across generations ----
/// <summary>The per-parent RMW gate survives a teardown (a write still in flight keeps the same gate).</summary>
[Fact]
public async Task Bit_RMW_gates_survive_a_shutdown()
{
var fake = new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0, 0] } };
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Bit", Host, "MAIN.Flags.1", TwinCATDataType.Bool));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.WriteAsync([new WriteRequest("Bit", true)], TestContext.Current.CancellationToken);
var gate = drv.BitRmwGateForTest(Host, "MAIN.Flags").ShouldNotBeNull();
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.WriteAsync([new WriteRequest("Bit", false)], TestContext.Current.CancellationToken);
drv.BitRmwGateForTest(Host, "MAIN.Flags").ShouldBeSameAs(gate);
}
// ---- MINOR 4: ShutdownAsync honours its token ----
/// <summary>
/// A cancelled ShutdownAsync stops waiting on a probe wedged in connect and throws
/// OperationCanceledException; the teardown itself still happened (tables emptied).
/// </summary>
[Fact]
public async Task Shutdown_honours_its_cancellation_token()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
var drv = NewDriver(factory, FastProbe, native: false);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
var sw = Stopwatch.StartNew();
await Should.ThrowAsync<OperationCanceledException>(() => drv.ShutdownAsync(cts.Token));
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ProbeJoinTimeout, "the wait ended on the token, not the join bound");
drv.DeviceCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
// The background teardown finishes once the wedge releases, and disposes the client the wedged
// connect produced (no dangling teardown left behind the test).
var teardown = drv.LastTeardownForTest.ShouldNotBeNull();
wedge.SetResult();
await teardown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
await WaitForAsync(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
factory.Clients[0].DisposeCount.ShouldBeGreaterThan(0);
}
// ---- MINOR 7: the real STRING / array encode path through the client planning helpers ----
/// <summary>
/// A STRING write through the driver lands as NUL-padded Windows-1252 bytes at the PLC symbol's width;
/// an over-long value is BadOutOfRange and a WSTRING symbol under a String tag BadTypeMismatch.
/// </summary>
[Fact]
public async Task String_writes_go_through_the_client_encode_plan()
{
var fake = new FakeTwinCATClient();
fake.SymbolShapes["MAIN.Name"] = TwinCATPlcSymbolShape.Classify("STRING(4)", DataTypeCategory.String, 5);
fake.SymbolShapes["MAIN.Wide"] = TwinCATPlcSymbolShape.Classify("WSTRING(4)", DataTypeCategory.String, 10);
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Name", Host, "MAIN.Name", TwinCATDataType.String),
new TwinCATTagDefinition("Wide", Host, "MAIN.Wide", TwinCATDataType.String));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[
new WriteRequest("Name", "äb"),
new WriteRequest("Name", "toolong"),
new WriteRequest("Wide", "x"),
], TestContext.Current.CancellationToken);
results.Select(r => r.StatusCode).ShouldBe(
[TwinCATStatusMapper.Good, TwinCATStatusMapper.BadOutOfRange, TwinCATStatusMapper.BadTypeMismatch]);
fake.RawWrites["MAIN.Name"].ShouldBe(new byte[] { 0xE4, (byte)'b', 0, 0, 0 });
fake.RawWrites.ShouldNotContainKey("MAIN.Wide");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>An array write through the driver lands as the codec's bytes at the PLC element stride.</summary>
[Fact]
public async Task Array_writes_go_through_the_client_encode_plan()
{
var fake = new FakeTwinCATClient();
var element = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2);
fake.SymbolShapes["MAIN.Buf"] = TwinCATPlcSymbolShape.Classify("ARRAY [0..3] OF INT", DataTypeCategory.Array, 8);
fake.ArrayShapes["MAIN.Buf"] = new TwinCATPlcArrayShape([4], 8, element);
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 2),
new TwinCATTagDefinition("BufAsReal", Host, "MAIN.Buf", TwinCATDataType.Real, ArrayLength: 2));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[
new WriteRequest("Buf", new[] { 1, -2 }),
new WriteRequest("BufAsReal", new[] { 1.0, 2.0 }),
], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
fake.RawWrites["MAIN.Buf"].ShouldBe(new byte[] { 0x01, 0x00, 0xFE, 0xFF }); // leading sub-range only
results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
}
// ---- MINOR 8: REAL / LREAL range ----
/// <summary>A REAL (scalar or array element) beyond float range is BadOutOfRange, never written as Infinity.</summary>
[Fact]
public async Task Out_of_range_REAL_values_are_BadOutOfRange()
{
Should.Throw<OverflowException>(() => AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, 1e300));
Should.Throw<OverflowException>(() => AdsTwinCATClient.ConvertForWrite(TwinCATDataType.LReal, "1e400"));
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, 3.5).ShouldBe(3.5f);
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, float.PositiveInfinity).ShouldBe(float.PositiveInfinity);
float.IsNaN((float)AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, double.NaN)).ShouldBeTrue();
var fake = new FakeTwinCATClient();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("Arr", Host, "MAIN.Arr", TwinCATDataType.Real, ArrayLength: 2));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1.0, 1e300 })], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
fake.ArrayWriteLog.ShouldBeEmpty();
}
}
@@ -0,0 +1,262 @@
using System.Collections.Concurrent;
using Shouldly;
using TwinCAT.TypeSystem;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #739 review fix round 2: a cancelled shutdown's background teardown is confined to its own generation
/// (I1); write outcomes feed per-device health (M1); a symbol-version re-arm survives an in-flight retry
/// and restarts the backoff (M2/M3); WSTRING / multi-dimensional encode plans (M5); infinity strings (M6).
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATReviewFixRound2Tests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static TwinCATProbeOptions FastProbe => new()
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(30),
Timeout = TimeSpan.FromMilliseconds(100),
};
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, TwinCATProbeOptions probe, bool native,
params TwinCATTagDefinition[] tags) =>
new(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags),
Probe = probe,
UseNativeNotifications = native,
}, "drv-1", factory);
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(10);
}
// ---- I1 ----
/// <summary>
/// The exact reviewed sequence: a poll read is wedged; ShutdownAsync is cancelled (its teardown
/// continues in the background); the same instance is re-initialised and subscribed; the wedge is
/// released and the OLD teardown completes. The NEW subscription keeps polling and delivering (positive
/// control), UnsubscribeAsync finds and stops it, and a final shutdown leaves nothing polling.
/// </summary>
[Fact]
public async Task A_cancelled_shutdowns_background_teardown_never_touches_the_next_generations_subscription()
{
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var counter = 0;
var clients = new ConcurrentQueue<FakeTwinCATClient>();
var factory = new FakeTwinCATClientFactory
{
Customise = () =>
{
// Client 1 (generation 1) wedges its first read; later clients answer an ever-changing value
// so every poll publishes.
var c = new ChangingValueFake(() => Interlocked.Increment(ref counter))
{
BlockRead = clients.IsEmpty ? wedge : null,
};
clients.Enqueue(c);
return c;
},
};
var tag = new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt);
var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false }, native: false, tag);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
await WaitForAsync(() => !clients.IsEmpty, TimeSpan.FromSeconds(2)); // gen-1 poll read is wedged
using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)))
await Should.ThrowAsync<OperationCanceledException>(() => drv.ShutdownAsync(cts.Token));
var oldTeardown = drv.LastTeardownForTest.ShouldNotBeNull();
oldTeardown.IsCompleted.ShouldBeFalse("the old teardown must still be running in the background");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var handle = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
wedge.SetResult();
await oldTeardown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
// Positive control: the new subscription delivers AFTER the old teardown finished.
var afterTeardown = events.Count(e => e.SubscriptionHandle.Equals(handle));
await WaitForAsync(() => events.Count(e => e.SubscriptionHandle.Equals(handle)) >= afterTeardown + 3, TimeSpan.FromSeconds(3));
events.Count(e => e.SubscriptionHandle.Equals(handle)).ShouldBeGreaterThanOrEqualTo(afterTeardown + 3);
// UnsubscribeAsync can still find and stop it.
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
var stoppedAt = events.Count(e => e.SubscriptionHandle.Equals(handle));
// Measured window: a sibling driver's poll publishes 5 times; the stopped loop must publish nothing.
var witnessFactory = new FakeTwinCATClientFactory { Customise = () => new ChangingValueFake(() => Environment.TickCount) };
var witnessDrv = NewDriver(witnessFactory, new TwinCATProbeOptions { Enabled = false }, native: false, tag);
await witnessDrv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var witnessEvents = 0;
witnessDrv.OnDataChange += (_, _) => Interlocked.Increment(ref witnessEvents);
_ = await witnessDrv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
await WaitForAsync(() => Volatile.Read(ref witnessEvents) >= 5, TimeSpan.FromSeconds(3));
Volatile.Read(ref witnessEvents).ShouldBeGreaterThanOrEqualTo(5);
await witnessDrv.ShutdownAsync(TestContext.Current.CancellationToken);
events.Count(e => e.SubscriptionHandle.Equals(handle)).ShouldBe(stoppedAt, "the unsubscribed loop must have stopped");
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>Answers each read with a new value so every poll cycle publishes.</summary>
private sealed class ChangingValueFake(Func<int> next) : FakeTwinCATClient
{
public override Task<(object? value, uint status)> ReadValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct)
{
if (BlockRead is not null) return base.ReadValueAsync(symbolPath, type, bitIndex, arrayCount, ct);
Values[symbolPath] = next();
return base.ReadValueAsync(symbolPath, type, bitIndex, arrayCount, ct);
}
}
// ---- M1 ----
/// <summary>
/// A write-only device with the probe off: a wire-failed write degrades; a later Good write is the device
/// answering — Healthy again with the stamp advanced. A refused write (BadNotWritable) changes nothing.
/// </summary>
[Fact]
public async Task A_good_write_clears_that_devices_comms_fault()
{
var fake = new FakeTwinCATClient();
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("W", Host, "MAIN.W", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
fake.ThrowOnWrite = true;
_ = await drv.WriteAsync([new WriteRequest("W", 1)], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
fake.ThrowOnWrite = false;
fake.WriteStatuses["MAIN.W"] = TwinCATStatusMapper.BadNotWritable;
_ = await drv.WriteAsync([new WriteRequest("W", 1)], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "a refusal says nothing about the comms fault");
await WaitForAsync(() => DateTime.UtcNow > initStamp, TimeSpan.FromSeconds(1));
fake.WriteStatuses.Remove("MAIN.W");
_ = await drv.WriteAsync([new WriteRequest("W", 1)], TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp);
}
// ---- M2 + M3 ----
/// <summary>
/// A symbol-version re-arm that lands while a retry of the refused registration is in flight is not
/// overwritten by that retry's failure: the next tick re-attempts at once. (Without the epoch the
/// failure re-imposed a 30 s+ backoff.)
/// </summary>
[Fact]
public async Task A_rearm_during_an_in_flight_retry_is_not_lost()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.T"] = new TwinCATTypeMismatchException("MAIN.T", "PLC STRING");
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["T"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
fake.AttemptsFor("MAIN.T").ShouldBe(1);
var inFlight = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
fake.PreAttemptGate = inFlight;
fake.FireSymbolVersionChanged(); // re-arm: the next tick starts a retry, which wedges on the gate
await WaitForAsync(() => fake.AttemptsFor("MAIN.T") >= 2, TimeSpan.FromSeconds(3));
fake.FireSymbolVersionChanged(); // a second program change while that retry is in flight
fake.PreAttemptGate = null;
inFlight.SetResult(); // the in-flight retry now fails (still refused)
await WaitForAsync(() => fake.AttemptsFor("MAIN.T") >= 3, TimeSpan.FromSeconds(3));
fake.AttemptsFor("MAIN.T").ShouldBeGreaterThanOrEqualTo(3);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
/// <summary>A re-arm restarts the refusal backoff from the initial delay instead of doubling the old one.</summary>
[Fact]
public async Task A_rearm_restarts_the_refusal_backoff()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.T"] = new TwinCATTypeMismatchException("MAIN.T", "PLC STRING");
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["T"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var reg = drv.GetDeviceState(Host)!.NativeRegistrations.Values.Single();
reg.RefusalBackoff.ShouldBe(TwinCATDriver.RefusedRetryInitial);
fake.FireSymbolVersionChanged();
await WaitForAsync(() => fake.AttemptsFor("MAIN.T") >= 2, TimeSpan.FromSeconds(3));
await WaitForAsync(() => reg.RefusalBackoff != TimeSpan.Zero, TimeSpan.FromSeconds(1));
reg.RefusalBackoff.ShouldBe(TwinCATDriver.RefusedRetryInitial);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
}
// ---- M5 ----
/// <summary>A WSTRING tag over a WSTRING symbol writes UTF-16LE with an aligned 2-byte terminator, zero-padded.</summary>
[Fact]
public async Task A_WSTRING_write_goes_through_the_client_encode_plan()
{
var fake = new FakeTwinCATClient();
fake.SymbolShapes["MAIN.W"] = TwinCATPlcSymbolShape.Classify("WSTRING(3)", DataTypeCategory.String, 8);
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("W", Host, "MAIN.W", TwinCATDataType.WString));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[new WriteRequest("W", "中x"), new WriteRequest("W", "abcd")], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange); // 4 chars do not fit WSTRING(3)
fake.RawWrites["MAIN.W"].ShouldBe(new byte[] { 0x2D, 0x4E, (byte)'x', 0, 0, 0, 0, 0 });
}
/// <summary>A multi-dimensional array is written flattened in PLC memory order (last index fastest).</summary>
[Fact]
public async Task A_multi_dimensional_array_write_is_flattened_row_major()
{
var fake = new FakeTwinCATClient();
var element = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2);
fake.SymbolShapes["MAIN.M"] = TwinCATPlcSymbolShape.Classify("ARRAY [0..1,0..2] OF INT", DataTypeCategory.Array, 12);
fake.ArrayShapes["MAIN.M"] = new TwinCATPlcArrayShape([2, 3], 12, element);
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
new TwinCATProbeOptions { Enabled = false }, native: false,
new TwinCATTagDefinition("M", Host, "MAIN.M", TwinCATDataType.Int, ArrayLength: 6));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var result = await drv.WriteAsync([new WriteRequest("M", new[] { 1, 2, 3, 4, 5, 6 })], TestContext.Current.CancellationToken);
result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
fake.RawWrites["MAIN.M"].ShouldBe(new byte[] { 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0 });
TwinCATArrayCodec.Decode(TwinCATDataType.Int, fake.RawWrites["MAIN.M"], new TwinCATArrayLayout(6, 2), false)
.ShouldBe(new short[] { 1, 2, 3, 4, 5, 6 });
}
// ---- M6 ----
/// <summary>An infinity spelled as a string passes like a float/double infinity; a finite overflow does not.</summary>
[Fact]
public void Infinity_strings_are_explicit_infinities()
{
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, "Infinity").ShouldBe(float.PositiveInfinity);
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.LReal, "-Infinity").ShouldBe(double.NegativeInfinity);
Should.Throw<OverflowException>(() => AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, "1e300"));
}
}