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
@@ -25,6 +25,11 @@ public sealed class ReadCommand : TwinCATTagCommandBase
"String / WString / Time / Date / DateTime / TimeOfDay (default 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 />
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<object?>().Select(e => Convert.ToString(e, System.Globalization.CultureInfo.InvariantCulture))) + "]");
}
finally
{
@@ -30,6 +30,16 @@ public sealed class WriteCommand : TwinCATTagCommandBase
IsRequired = true)]
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 />
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
}
}
/// <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
{
"1" or "true" or "on" or "yes" => true,