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).")]
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,
@@ -469,6 +469,20 @@ 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;
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 result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken)
.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 />
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
{
@@ -523,8 +654,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var (parent, _, _, parentStatus) = await DescribeSymbolAsync(registerPath, cancellationToken)
.ConfigureAwait(false);
if (parentStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException(
$"Resolving {registerPath} for a notification failed with status 0x{parentStatus:X8}");
throw RegistrationFailure(registerPath, "resolving the parent word", parentStatus);
if (TwinCATBitAccess.CheckParent(bit, parent) is { } bitReason)
throw new TwinCATTypeMismatchException(symbolPath, bitReason);
clrType = typeof(byte[]);
@@ -538,8 +668,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var (shape, array, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException(
$"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}");
throw RegistrationFailure(symbolPath, "resolving the symbol", symbolStatus);
if (arrayCount is int count)
{
@@ -582,8 +711,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
registerPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
.ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
throw new InvalidOperationException(
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {registerPath}");
throw RegistrationFailure(registerPath, $"AddDeviceNotificationEx (ADS error {result.ErrorCode})",
MapAndSignal((uint)result.ErrorCode));
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
{
@@ -594,6 +723,17 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
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>
/// 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
@@ -618,15 +758,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
else if (TwinCATTemporalCodec.IsTemporal(reg.Type) && value is uint 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)
{
try
{
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); }
catch { /* consumer-side errors stay on this managed task, not the ADS thread */ }
}
@@ -877,9 +1026,15 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// </summary>
public void Dispose()
{
IsDeleted = true;
// Fire-and-forget AMS call — caller has already committed to the tear-down.
_ = 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
@@ -916,14 +1071,43 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
TwinCATDataType.UDInt => Convert.ToUInt32(value),
TwinCATDataType.LInt => Convert.ToInt64(value),
TwinCATDataType.ULInt => Convert.ToUInt64(value),
TwinCATDataType.Real => Convert.ToSingle(value),
TwinCATDataType.LReal => Convert.ToDouble(value),
TwinCATDataType.Real => ToSingleInRange(value),
TwinCATDataType.LReal => ToDoubleInRange(value),
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
TwinCATDataType.Time or TwinCATDataType.Date
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => TwinCATTemporalCodec.Encode(type, value),
_ => 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
{
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>
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>
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
@@ -119,6 +125,39 @@ public interface ITwinCATClient : IDisposable
object? value,
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>
/// 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.
@@ -19,6 +19,84 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// </remarks>
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>
/// <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>
@@ -32,6 +32,36 @@ internal static class TwinCATBitAccess
: $"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>
/// <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>
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);
}
// 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>
/// <param name="bytes">The raw symbol bytes (2(n+1) for a <c>WSTRING(n)</c>).</param>
/// <returns>The decoded value.</returns>