diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md index 11269c53..f4ac2c7c 100644 --- a/docs/drivers/TwinCAT.md +++ b/docs/drivers/TwinCAT.md @@ -135,6 +135,32 @@ found against a real TwinCAT 2 PLC: registers, and the probe-tick retry re-checks it after a PLC program change. Reads go through the Beckhoff symbol cache, which converts numeric values by the PLC's own type, so only the string-kind rule applies there. +- **Sizes come from the resolved data type, not the symbol.** On a TwinCAT 2 + target the Beckhoff library reports a dotted *member* symbol's `ByteSize` as if + the PLC's byte count were a bit count (`.OPC_MACHINEDATA.PROCESSSTATUS` + `STRING(80)` → 11 B, an `INT` member → 1, a `UDINT` → 1) while its resolved + `DataType.ByteSize` is correct (81 / 2 / 4, matching raw `SYM_INFOBYNAMEEX`). + String reads therefore address the symbol's index group/offset explicitly at + the type's width — sizing them by the symbol truncated `"ready for operation"` + to `"ready for o"` at Good. +- **A `VAR_IN_OUT` member is a reference, not a value.** TC2 names it + `STRING(20) (VAR_IN_OUT)` (4 bytes) but the library resolves its type to the + plain `STRING(20)`; the symbol's own name wins and such a tag is refused + (`BadTypeMismatch`) instead of publishing the memory at the pointer as text. + +### Live validation (2026-10-07, TwinCAT 2.11 Elma cleaners) + +Read-only, against Z33856 (`10.201.45.236.1.1:801`) and Z33733 +(`5.25.53.46.1.1:801`) over the VPN, through an in-process +`Beckhoff.TwinCAT.Ads.TcpRouter` in a scratch harness: ReadState RUN; browse +discovered 45,083 / 42,666 variables incl. the `INT16`/`UINT16` globals +(`.ACTUALOPERATIONMODE`, `.ADSSTATE_RUN`) and 3,663 / 3,564 leading-dot names, no +empty names; `.ADSSTATE_RUN` = 5 with bits `.0`/`.1`/`.2` = true/false/true; +work-order and part-number strings whose PLC buffers carry a stale tail after +the NUL (`68261005\0` + `01`) read as `68261005` through both poll and native +notifications; `DInt` over a `STRING(80)` → `BadTypeMismatch` in both paths with +the rest of the subscription Good. No PLC value held a genuine non-ASCII string +to exercise Windows-1252 live — that rule is unit-tested only. ## Capability surface 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 3b0a23cd..07057e61 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -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 /// compares a declared type against. Uses the Beckhoff symbol /// cache (lookup on), so repeat calls are local. /// - 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); + } + + /// + /// Reads exactly 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. + /// + 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 */ } + } } /// @@ -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( 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 9f6c6ac9..c2288c90 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -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( diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs index 263551b5..5fa6d4cf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs @@ -36,9 +36,24 @@ internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int B /// The PLC type name (STRING(80), INT16, ST_LOGDATA, …). /// The resolved type category, or null when the type system has none. /// The symbol's byte size. + /// The resolved data type's byte size, when the type system has one. + /// It wins over : 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. + /// The symbol's OWN type name, when it differs from the resolved one. A + /// TwinCAT 2 VAR_IN_OUT member is named STRING(20) (VAR_IN_OUT) — a 4-byte reference — + /// while the library resolves its data type to the plain STRING(20); the reference marker + /// wins, so the symbol is never read as the value it points at. /// The classified shape. - 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) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs index 1aa965d6..e83ced13 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs @@ -37,6 +37,47 @@ public sealed class TwinCATTypeGuardTests Plc("INT16", 2).IsFloat.ShouldBeFalse(); } + /// + /// Live on the TwinCAT 2.11 Elma cleaner Z33856 (2026-10-07): for a dotted MEMBER path the + /// Beckhoff library reports the symbol's ByteSize as if the PLC's byte count were a bit + /// count — .OPC_MACHINEDATA.PROCESSSTATUS (STRING(80)) came back as 11 bytes, an + /// INT member as 1, a UDINT as 1 — while its resolved DataType.ByteSize was + /// the true 81 / 2 / 4 (raw SYM_INFOBYNAMEEX answers 81). Sizing a string read by the + /// symbol truncated "ready for operation" to "ready for o" at Good, and the + /// notification width check refused every numeric member. The resolved type's size wins. + /// + [Fact] + public void Classify_prefers_the_resolved_data_type_size_over_the_symbol_size() + { + TwinCATPlcSymbolShape.Classify("STRING(80)", DataTypeCategory.String, byteSize: 11, dataTypeByteSize: 81) + .ByteSize.ShouldBe(81); + var member = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, byteSize: 1, dataTypeByteSize: 2); + member.ByteSize.ShouldBe(2); + TwinCATTypeGuard.CheckNotification(TwinCATDataType.Int, member).ShouldBeNull(); + TwinCATPlcSymbolShape.Classify("UDINT", DataTypeCategory.Primitive, byteSize: 1, dataTypeByteSize: 4).ByteSize.ShouldBe(4); + + // No resolved type (or a zero size) falls back to the symbol's own size. + TwinCATPlcSymbolShape.Classify("STRING(30)", null, byteSize: 31, dataTypeByteSize: null).ByteSize.ShouldBe(31); + TwinCATPlcSymbolShape.Classify("STRING(30)", null, byteSize: 31, dataTypeByteSize: 0).ByteSize.ShouldBe(31); + } + + /// + /// Live on Z33856 (2026-10-07): P_INITINPUT_ACTION.SLOTID is a TwinCAT 2 + /// STRING(20) (VAR_IN_OUT) — a 4-byte reference — but the Beckhoff library resolves its + /// DataType to the plain STRING(20) (category String). Classifying by the resolved + /// type alone let a String tag read 21 bytes starting at the pointer and publish them as text at + /// Good ("n±\u008dâa»‰â…"). The symbol's own type name marks it a reference, and that wins. + /// + [Fact] + public void Classify_treats_a_var_in_out_symbol_as_non_scalar_even_when_its_resolved_type_is_a_string() + { + var shape = TwinCATPlcSymbolShape.Classify("STRING(20)", DataTypeCategory.String, byteSize: 4, + dataTypeByteSize: 21, symbolTypeName: "STRING(20) (VAR_IN_OUT)"); + shape.Kind.ShouldBe(TwinCATPlcKind.NonScalar); + TwinCATTypeGuard.CheckRead(TwinCATDataType.String, shape).ShouldNotBeNull(); + TwinCATTypeGuard.CheckNotification(TwinCATDataType.String, shape).ShouldNotBeNull(); + } + // ---- read path (symbol-aware: only the string kind can go wrong) ---- ///