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);
+ }
+}