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
This commit is contained in:
Joseph Doherty
2026-10-22 14:15:29 -04:00
parent 40c7493829
commit f17ba8e258
10 changed files with 490 additions and 7 deletions
@@ -184,4 +184,24 @@ public sealed class WriteCommandParseValueTests
{
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, ","));
}
}
@@ -136,6 +136,24 @@ internal class FakeTwinCATClient : ITwinCATClient
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();
ArrayWriteLog.Add((symbolPath, type, elements));
Values[symbolPath] = elements;
return Task.FromResult(WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good);
}
/// <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>
@@ -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);
}
}