fix(twincat): size strings by the resolved type and refuse VAR_IN_OUT references (live TC2 gate)

Found by the read-only live gate against the TwinCAT 2.11 Elma cleaners
Z33856 / Z33733:

- The Beckhoff library reports a TC2 dotted MEMBER symbol's ByteSize as if the
  PLC's byte count were a bit count (STRING(80) -> 11, INT -> 1, UDINT -> 1)
  while DataType.ByteSize is right (81/2/4, raw SYM_INFOBYNAMEEX says 81). The
  previous commit's typeof(byte[]) read sliced by the symbol size, so
  .OPC_MACHINEDATA.PROCESSSTATUS read "ready for o" at Good; the notification
  width check would also have refused every numeric member. The shape now takes
  the resolved type's size, and string reads go by index group/offset (or a
  fresh handle for 0xF005) at that width.
- P_INITINPUT_ACTION.SLOTID is "STRING(20) (VAR_IN_OUT)" - a 4-byte reference -
  but resolves to a plain STRING(20); a String tag read 21 bytes at the pointer
  as text at Good. The symbol's own VAR_IN_OUT marker now classifies it
  NonScalar -> BadTypeMismatch.
This commit is contained in:
Joseph Doherty
2026-10-07 14:27:22 -04:00
parent 75754478ac
commit 8060cfd2a5
5 changed files with 136 additions and 15 deletions
@@ -129,7 +129,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// Scalar read: check the declared type against the PLC symbol first. The symbol is
// resolved through the Beckhoff symbol cache (the same lookup ReadValueAsync makes
// internally), so this costs no extra round trip after the first read.
var (shape, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
var (shape, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) return (null, symbolStatus);
if (TwinCATTypeGuard.CheckRead(type, shape) is not null)
@@ -137,14 +137,17 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// A STRING / WSTRING is read as its raw bytes at the PLC's own declared width and
// decoded here: it ends at the first NUL, and a STRING is Windows-1252 rather than the
// host culture's ANSI code page the Beckhoff marshaller would fall back to.
// host culture's ANSI code page the Beckhoff marshaller would fall back to. The width is
// the RESOLVED type's (shape.ByteSize) and the read is addressed explicitly: asking the
// library for typeof(byte[]) slices by the symbol's own ByteSize, which a TwinCAT 2
// member symbol under-reports (STRING(80) → 11 B, live on a TC 2.11 CX).
if (type is TwinCATDataType.String or TwinCATDataType.WString)
{
var raw = await _client.ReadValueAsync(symbolPath, typeof(byte[]), cancellationToken)
var (bytes, readStatus) = await ReadRawAsync(symbolPath, symbol!, shape.ByteSize, cancellationToken)
.ConfigureAwait(false);
if (raw.ErrorCode != AdsErrorCode.NoError)
return (null, MapAndSignal((uint)raw.ErrorCode));
return (DecodeString(type, raw.Value as byte[] ?? [], StringsAreUtf8()), TwinCATStatusMapper.Good);
return readStatus == TwinCATStatusMapper.Good
? (DecodeString(type, bytes, StringsAreUtf8()), TwinCATStatusMapper.Good)
: (null, readStatus);
}
var clrType = MapToClrType(type);
@@ -167,19 +170,55 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
/// cache (lookup on), so repeat calls are local.
/// </summary>
private async Task<(TwinCATPlcSymbolShape shape, uint status)> DescribeSymbolAsync(
private async Task<(TwinCATPlcSymbolShape shape, IAdsSymbol? symbol, uint status)> DescribeSymbolAsync(
string symbolPath, CancellationToken cancellationToken)
{
var result = await _client.ReadSymbolAsync(symbolPath, cancellationToken).ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
return (default, MapAndSignal((uint)result.ErrorCode));
return (default, null, MapAndSignal((uint)result.ErrorCode));
if (result.Value is not { } symbol)
return (default, TwinCATStatusMapper.BadNodeIdUnknown);
return (default, null, TwinCATStatusMapper.BadNodeIdUnknown);
// Resolve aliases (T_MaxString → STRING(255)) so the guard sees the base type.
// Resolve aliases (T_MaxString → STRING(255)) so the guard sees the base type, and take the
// resolved type's byte size — a TwinCAT 2 member symbol's own ByteSize is under-reported.
var dataType = symbol.DataType?.Resolve();
return (TwinCATPlcSymbolShape.Classify(dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize),
TwinCATStatusMapper.Good);
var shape = TwinCATPlcSymbolShape.Classify(
dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize, dataType?.ByteSize, symbol.TypeName);
return (shape, symbol, TwinCATStatusMapper.Good);
}
/// <summary>
/// Reads exactly <paramref name="length"/> raw bytes of a resolved symbol: by its
/// index group/offset, or — for a handle-addressed symbol (0xF005, whose offset is a handle
/// that may be stale) — through a fresh variable handle.
/// </summary>
private async Task<(byte[] bytes, uint status)> ReadRawAsync(
string symbolPath, IAdsSymbol symbol, int length, CancellationToken cancellationToken)
{
const uint SymbolValueByHandle = 0xF005;
if (symbol.IndexGroup != SymbolValueByHandle)
{
var direct = await _client.ReadAsync(symbol.IndexGroup, symbol.IndexOffset, length, cancellationToken)
.ConfigureAwait(false);
return direct.ErrorCode == AdsErrorCode.NoError
? (direct.Data.ToArray(), 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 read = await _client.ReadAsync(handle.Handle, length, cancellationToken).ConfigureAwait(false);
return read.ErrorCode == AdsErrorCode.NoError
? (read.Data.ToArray(), TwinCATStatusMapper.Good)
: ([], MapAndSignal((uint)read.ErrorCode));
}
finally
{
try { await _client.DeleteVariableHandleAsync(handle.Handle, CancellationToken.None).ConfigureAwait(false); }
catch { /* best-effort release */ }
}
}
/// <summary>
@@ -271,7 +310,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// A notification carries raw bytes decoded as the DECLARED type, so a declared type that
// cannot decode the PLC symbol (width, float/integer or string kind differ) would publish
// wrong values at Good. Refuse it here; the driver isolates the one tag to Bad.
var (shape, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
var (shape, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException(
@@ -279,7 +279,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
if (status == TwinCATStatusMapper.BadTypeMismatch)
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
"does not match the PLC symbol '{Symbol}' (string vs numeric)",
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch or a reference/container)",
_driverInstanceId, reference, def.DataType, symbolName);
else
_logger.LogWarning(
@@ -36,9 +36,24 @@ internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int B
/// <param name="typeName">The PLC type name (<c>STRING(80)</c>, <c>INT16</c>, <c>ST_LOGDATA</c>, …).</param>
/// <param name="category">The resolved type category, or null when the type system has none.</param>
/// <param name="byteSize">The symbol's byte size.</param>
/// <param name="dataTypeByteSize">The resolved data type's byte size, when the type system has one.
/// It wins over <paramref name="byteSize"/>: on a TwinCAT 2 target the Beckhoff library reports a
/// dotted MEMBER symbol's size as if the PLC's byte count were a bit count (STRING(80) → 11,
/// INT → 1, UDINT → 1; live on a TC 2.11 CX), while the resolved type carries the true size.</param>
/// <param name="symbolTypeName">The symbol's OWN type name, when it differs from the resolved one. A
/// TwinCAT 2 <c>VAR_IN_OUT</c> member is named <c>STRING(20) (VAR_IN_OUT)</c> — a 4-byte reference —
/// while the library resolves its data type to the plain <c>STRING(20)</c>; the reference marker
/// wins, so the symbol is never read as the value it points at.</param>
/// <returns>The classified shape.</returns>
public static TwinCATPlcSymbolShape Classify(string? typeName, DataTypeCategory? category, int byteSize)
public static TwinCATPlcSymbolShape Classify(
string? typeName, DataTypeCategory? category, int byteSize, int? dataTypeByteSize = null,
string? symbolTypeName = null)
{
if (symbolTypeName?.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase) == true
|| typeName?.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase) == true)
return new(TwinCATPlcKind.NonScalar, byteSize, false, (symbolTypeName ?? typeName)!.Trim());
if (dataTypeByteSize is > 0) byteSize = dataTypeByteSize.Value;
var name = (typeName ?? string.Empty).Trim();
var match = StringTypeName.Match(name);
if (match.Success || category == DataTypeCategory.String)