From f17ba8e2585e0036bfe83f036c37f23bc141d3e6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:15:29 -0400 Subject: [PATCH] feat(twincat): array and STRING writes through the codecs (U9) Array tags were advertised writable but every write threw InvalidCastException in ConvertForWrite (reported BadTypeMismatch); STRING writes went through the Beckhoff marshaller with no length check against STRING(n) and the host's ANSI code page, which the read side deliberately avoids. - Array write: TwinCATArrayCodec.ToWriteElements converts exactly ArrayLength elements to the declared type (CLR lists convert - ScadaBridge writes int[] to Int16 nodes; a length mismatch or out-of-range element is BadOutOfRange, a scalar value BadTypeMismatch) before any wire traffic; the ADS client plans the layout from the resolved PLC array (TwinCATTypeGuard.CheckArray, as the read does), encodes with TwinCATArrayCodec.Encode - the exact inverse of Decode - and writes the raw bytes (new ITwinCATClient.WriteArrayAsync). - STRING / WSTRING write: encoded at the PLC's declared width by TwinCATStringCodec.EncodeString/EncodeWString (Windows-1252 or UTF-8 per target, NUL-padded, strict: an over-long value or unrepresentable character is OverflowException -> BadOutOfRange) and written as raw bytes. - TwinCAT CLI read/write gain --array-length (+ --separator) for the hardware gate. Refs #739 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../Commands/ReadCommand.cs | 11 +- .../Commands/WriteCommand.cs | 38 +++- .../AdsTwinCATClient.cs | 39 ++++ .../ITwinCATClient.cs | 17 ++ .../TwinCATArrayCodec.cs | 78 +++++++ .../TwinCATDriver.cs | 26 ++- .../TwinCATStringCodec.cs | 51 +++++ .../WriteCommandParseValueTests.cs | 20 ++ .../FakeTwinCATClient.cs | 18 ++ .../TwinCATArrayStringWriteTests.cs | 199 ++++++++++++++++++ 10 files changed, 490 insertions(+), 7 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayStringWriteTests.cs diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs index 96fe1d87..4603825d 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs @@ -25,6 +25,11 @@ public sealed class ReadCommand : TwinCATTagCommandBase "String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")] public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt; + /// Gets the element count for an array read; null reads a scalar. + [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; } + /// public override async ValueTask ExecuteAsync(IConsole console) { @@ -38,7 +43,8 @@ public sealed class ReadCommand : TwinCATTagCommandBase DeviceHostAddress: Gateway, SymbolPath: SymbolPath, DataType: DataType, - Writable: false); + Writable: false, + ArrayLength: ArrayLength); var options = BuildOptions([tag]); await using var driver = new TwinCATDriver(options, DriverInstanceId); @@ -47,6 +53,9 @@ public sealed class ReadCommand : TwinCATTagCommandBase await driver.InitializeAsync("{}", ct); var snapshot = await driver.ReadAsync([tagName], ct); 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().Select(e => Convert.ToString(e, System.Globalization.CultureInfo.InvariantCulture))) + "]"); } finally { diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/WriteCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/WriteCommand.cs index f72adea4..a1f882ef 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/WriteCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/WriteCommand.cs @@ -30,6 +30,16 @@ public sealed class WriteCommand : TwinCATTagCommandBase IsRequired = true)] public string Value { get; init; } = default!; + /// Gets the element count for an array write; null writes a scalar. + [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; } + + /// Gets the element separator for an array write. + [CommandOption("separator", Description = "Element separator for --array-length writes (default ',').")] + public string Separator { get; init; } = ","; + /// public override async ValueTask ExecuteAsync(IConsole console) { @@ -48,10 +58,13 @@ public sealed class WriteCommand : TwinCATTagCommandBase DeviceHostAddress: Gateway, SymbolPath: SymbolPath, DataType: DataType, - Writable: true); + Writable: true, + ArrayLength: ArrayLength); 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); try @@ -113,6 +126,27 @@ public sealed class WriteCommand : TwinCATTagCommandBase } } + /// + /// Parse an array --value: -separated elements, each parsed by + /// . The element count must equal — the driver + /// refuses a mismatch with BadOutOfRange, but the CLI says so before touching the PLC. + /// + /// The raw separated element list. + /// The element type. + /// The expected element count (--array-length). + /// The element separator. + /// The parsed elements. + 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 { "1" or "true" or "on" or "yes" => true, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs index a143d018..1781bedf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -469,6 +469,23 @@ internal sealed class AdsTwinCATClient : ITwinCATClient { 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; + if (TwinCATTypeGuard.CheckRead(type, shape) is not null) return TwinCATStatusMapper.BadTypeMismatch; + var text = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + var encoded = type == TwinCATDataType.WString + ? TwinCATStringCodec.EncodeWString(text, shape.ByteSize) + : TwinCATStringCodec.EncodeString(text, shape.ByteSize, StringsAreUtf8()); + return await WriteRawAsync(symbolPath, symbol!, encoded, cancellationToken).ConfigureAwait(false); + } + var converted = ConvertForWrite(type, value); var result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken) .ConfigureAwait(false); @@ -482,6 +499,28 @@ internal sealed class AdsTwinCATClient : ITwinCATClient } } + /// + public async Task WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList 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; + if (TwinCATTypeGuard.CheckArray(type, elements.Count, shape, array, out var layout) is not null) + return TwinCATStatusMapper.BadTypeMismatch; + var bytes = TwinCATArrayCodec.Encode(type, elements, layout, StringsAreUtf8()); + return await WriteRawAsync(symbolPath, symbol!, bytes, cancellationToken).ConfigureAwait(false); + } + catch (AdsErrorException ex) + { + return MapAndSignal((uint)ex.ErrorCode); + } + } + /// public async Task WriteBitAsync(string parentPath, int bit, bool value, CancellationToken cancellationToken) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs index 1ffeb6fa..b44f3038 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs @@ -119,6 +119,23 @@ public interface ITwinCATClient : IDisposable object? value, CancellationToken cancellationToken); + /// + /// Writes an array tag: (already converted to the declared element type by + /// ) as raw bytes at the layout planned from the RESOLVED + /// PLC array () and encoded by + /// — 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. + /// + /// The ADS symbol path of the array. + /// The declared element type. + /// The converted elements (the authored ArrayLength of them). + /// Cancellation token for the write. + /// The mapped OPC UA status: Good; BadTypeMismatch when the declared element type / + /// length cannot address the PLC array; the ADS write's status otherwise. + /// A string element does not fit the PLC element's width. + Task WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList elements, + CancellationToken cancellationToken); + /// /// Sets or clears one bit of an integer parent word — the write half of a BOOL-within-word tag /// (MAIN.Flags.3; ADS has no symbol for WORD.N). A read-modify-write of the parent's raw diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs index d008a714..d8fa56c1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs @@ -19,6 +19,84 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; /// internal static class TwinCATArrayCodec { + /// + /// Converts a client-supplied array value into exactly elements of the + /// declared type, each converted the way a scalar write converts ( + /// — so TIME/DATE elements become their encoded uint). Any CLR collection is accepted and the + /// element type is CONVERTED, not exact-matched: ScadaBridge writes an int[] to an Int16 node. + /// + /// The tag's declared element type. + /// The written value — an array or list. + /// The tag's authored ArrayLength. + /// The converted elements, in order. + /// The value is not a collection (a scalar, a string, null), or + /// an element cannot convert. + /// The element count differs from , or an + /// element is outside the declared type's range. + 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(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]; + } + + /// + /// Encodes converted elements () into the PLC's raw bytes at the planned + /// layout — the exact inverse of : little-endian at the element stride, a + /// STRING / WSTRING element encoded by within its fixed stride. + /// + /// The tag's declared element type. + /// The converted elements; exactly . + /// The layout planned from the PLC array (). + /// True when the target reports UTF-8 STRING data (else Windows-1252). + /// Exactly bytes. + /// The element count differs from the layout. + /// A string element does not fit its stride. + public static byte[] Encode(TwinCATDataType declared, IReadOnlyList 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; + } + /// Decodes .ElementCount elements of . /// The tag's declared element type. /// The PLC bytes from the array's first element (at least ). diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 2a5d4d94..30d9f488 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -508,17 +508,26 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery try { - var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); + var bitIndex = def.DataType == TwinCATDataType.Bool ? parsed?.BitIndex : null; + // Shape checks run BEFORE any wire traffic (connect included): an array write needs exactly + // ArrayLength elements, each converted to the declared element type (a CLR int[] to an Int16 + // array converts; it is not exact-matched) — a length mismatch / out-of-range element is + // OverflowException (BadOutOfRange), a scalar value InvalidCastException (BadTypeMismatch). + var bitValue = bitIndex is not null ? Convert.ToBoolean(w.Value) : false; + var elements = bitIndex is null && def.ArrayLength is int arrayLength + ? TwinCATArrayCodec.ToWriteElements(def.DataType, w.Value, arrayLength) + : null; + + var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); // BOOL-within-word write — a read-modify-write of the parent word at the parent's OWN // width and on raw bytes (the client resolves the parent and checks the bit against it, the // notification path's model), under a per-parent lock so concurrent bit writers to the same // word don't lose each other's updates. - if (def.DataType == TwinCATDataType.Bool && parsed?.BitIndex is int bit) + if (bitIndex is int bit) { - var parentPath = (parsed with { BitIndex = null }).ToAdsSymbolName(); - var bitValue = Convert.ToBoolean(w.Value); + var parentPath = (parsed! with { BitIndex = null }).ToAdsSymbolName(); var gate = _bitRmwLocks.GetOrAdd( $"{def.DeviceHostAddress}|{parentPath}", static _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -535,6 +544,15 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery } var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; + if (elements is not null) + { + // Array tag: raw bytes encoded by TwinCATArrayCodec at the layout planned from the PLC + // array — the inverse of the array read. + results[i] = new WriteResult(await client.WriteArrayAsync( + symbolName, def.DataType, elements, cancellationToken).ConfigureAwait(false)); + continue; + } + var status = await client.WriteValueAsync( symbolName, def.DataType, parsed?.BitIndex, w.Value, cancellationToken).ConfigureAwait(false); results[i] = new WriteResult(status); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStringCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStringCodec.cs index d3a90c1f..bdaff2ae 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStringCodec.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStringCodec.cs @@ -50,6 +50,57 @@ internal static class TwinCATStringCodec 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); + + /// + /// Encodes a value into an IEC STRING(n) buffer of the PLC's declared width (n+1 bytes): the + /// inverse of — Windows-1252 unless , NUL-terminated + /// and NUL-padded so no stale tail of a longer earlier value survives. + /// + /// The value to write. + /// The PLC symbol's byte width (n+1 for a STRING(n)). + /// True when the target reports UTF-8 string data. + /// Exactly bytes. + /// The value does not fit STRING(n), or holds a character the + /// target encoding cannot represent. + public static byte[] EncodeString(string value, int byteWidth, bool utf8) => + Encode(value, byteWidth, utf8 ? StrictUtf8 : StrictWindows1252, terminatorBytes: 1, "STRING"); + + /// + /// Encodes a value into an IEC WSTRING(n) buffer (UTF-16LE, 2(n+1) bytes): the inverse of + /// , terminated by an aligned U+0000 and zero-padded. + /// + /// The value to write. + /// The PLC symbol's byte width. + /// Exactly bytes. + /// The value does not fit WSTRING(n). + 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; + } + /// Decodes an IEC WSTRING(n) buffer (UTF-16LE): ends at the first aligned U+0000. /// The raw symbol bytes (2(n+1) for a WSTRING(n)). /// The decoded value. diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/WriteCommandParseValueTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/WriteCommandParseValueTests.cs index 89ed5c22..a06662a7 100644 --- a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/WriteCommandParseValueTests.cs +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/WriteCommandParseValueTests.cs @@ -184,4 +184,24 @@ public sealed class WriteCommandParseValueTests { ReadCommand.SynthesiseTagName(symbol, type).ShouldBe(expected); } + + /// An array value splits on the separator and parses each element per --type (#739 U9 gate tooling). + [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" }); + } + + /// A count that differs from --array-length is refused before the PLC is touched. + [Fact] + public void ParseArrayValue_refuses_a_count_mismatch() + { + Should.Throw( + () => WriteCommand.ParseArrayValue("1,2", TwinCATDataType.Int, 3, ",")); + Should.Throw( + () => WriteCommand.ParseArrayValue("1", TwinCATDataType.Int, 0, ",")); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs index e31340a5..39f795be 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs @@ -136,6 +136,24 @@ internal class FakeTwinCATClient : ITwinCATClient return Task.FromResult(status); } + /// Gets the log of every call. + public List<(string symbol, TwinCATDataType type, IReadOnlyList elements)> ArrayWriteLog { get; } = new(); + + /// Simulates an array write: records it, stores the elements and answers from . + /// The array symbol path. + /// The declared element type. + /// The converted elements. + /// The cancellation token. + /// The write status. + public virtual Task WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList elements, + CancellationToken ct) + { + if (ThrowOnWrite) throw Exception ?? new InvalidOperationException(); + ArrayWriteLog.Add((symbolPath, type, elements)); + Values[symbolPath] = elements; + return Task.FromResult(WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good); + } + /// Raw bytes of bit-write parent words, by parent path (#739 H18). The byte count is the parent's width. public Dictionary ParentWords { get; } = new(StringComparer.OrdinalIgnoreCase); /// Optional parent shapes; absent = an unsigned integer word as wide as its entry. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayStringWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayStringWriteTests.cs new file mode 100644 index 00000000..b42c3f0a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayStringWriteTests.cs @@ -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; + +/// +/// #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)). +/// +[Trait("Category", "Unit")] +public sealed class TwinCATArrayStringWriteTests +{ + private const string Host = "ads://5.23.91.23.1.1:851"; + + // ---- string codec: encode ---- + + /// A STRING(n) encodes NUL-terminated and NUL-padded to the PLC width, in Windows-1252. + [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 }); + } + + /// The encode is the decode's inverse (a STRING(n) of exactly n characters fits). + [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); + } + + /// A value longer than STRING(n), or a character the target encoding cannot hold, is out of range. + [Fact] + public void EncodeString_refuses_a_value_that_does_not_fit() + { + Should.Throw(() => TwinCATStringCodec.EncodeString("ABC", 3, utf8: false)); + Should.Throw(() => TwinCATStringCodec.EncodeString("中", 10, utf8: false)); + Should.Throw(() => TwinCATStringCodec.EncodeString("ää", 4, utf8: true)); // 4 bytes + NUL + } + + /// A WSTRING(n) encodes UTF-16LE with a 2-byte terminator and round-trips. + [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(() => TwinCATStringCodec.EncodeWString("Hi", 4)); + } + + // ---- array codec: elements + encode ---- + + /// + /// A CLR list converts element-wise to the declared type (ScadaBridge writes an int[] to an + /// Int16 node); the element type is converted, not exact-matched. + /// + [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 { 1, 2.5 }, 2) + .ShouldBe(new object[] { 1f, 2.5f }); + TwinCATArrayCodec.ToWriteElements(TwinCATDataType.String, new[] { "a", "b" }, 2) + .ShouldBe(new object[] { "a", "b" }); + } + + /// A length mismatch and an out-of-range element are out of range; a scalar is a type mismatch. + [Fact] + public void ToWriteElements_refuses_a_wrong_shape() + { + Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 1, 2 }, 3)); + Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 70_000 }, 1)); + Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, 5, 1)); + Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.String, "abc", 3)); + Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, null, 1)); + } + + /// Encode lays elements out little-endian at the PLC stride — the exact inverse of Decode. + [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 }); + } + + /// A string element longer than the element's STRING(n) is out of range. + [Fact] + public void Encode_refuses_a_string_element_wider_than_its_stride() + { + Should.Throw(() => + TwinCATArrayCodec.Encode(TwinCATDataType.String, ["toolong"], new TwinCATArrayLayout(1, 4), utf8: false)); + Should.Throw(() => + 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().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); + } + + /// An array tag's write sends ArrayLength converted elements through WriteArrayAsync. + [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) + } + + /// + /// 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. + /// + [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); + } + + /// An over-long string surfaced by the client as OverflowException is BadOutOfRange, not a comms fault. + [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); + } +}