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 3aed709e..15bb1e8c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs @@ -24,7 +24,9 @@ internal enum TwinCATPlcKind /// The symbol's byte size as the PLC reports it. /// True for a REAL / LREAL scalar. /// The (alias-resolved) PLC type name, for log messages. -internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName) +/// True for an ARRAY (a with array category or name). +internal readonly record struct TwinCATPlcSymbolShape( + TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName, bool IsArray = false) { private static readonly Regex StringTypeName = new(@"^(W?)STRING(\s*\(\s*\d+\s*\))?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); @@ -71,7 +73,8 @@ internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int B || name.StartsWith("POINTER TO", StringComparison.OrdinalIgnoreCase) || name.StartsWith("REFERENCE TO", StringComparison.OrdinalIgnoreCase) || name.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase)) - return new(TwinCATPlcKind.NonScalar, byteSize, false, name); + return new(TwinCATPlcKind.NonScalar, byteSize, false, name, + IsArray: category == DataTypeCategory.Array || name.StartsWith("ARRAY", StringComparison.OrdinalIgnoreCase)); var mapped = AdsTwinCATClient.MapSymbolTypeName(name); return new(TwinCATPlcKind.Scalar, byteSize, mapped is TwinCATDataType.Real or TwinCATDataType.LReal, name); @@ -115,11 +118,14 @@ internal readonly record struct TwinCATArrayLayout(int ElementCount, int Stride) /// /// Read () goes through the Beckhoff symbol cache, which /// decodes by the PLC's OWN type and converts the value to the declared CLR type — a numeric -/// pair is value-converted, never reinterpreted. Only string-ness can go wrong there: a -/// numeric tag over a PLC string got a string object back at Good. +/// pair is value-converted, never reinterpreted. Only string-ness can go wrong there (a +/// numeric tag over a PLC string got a string object back at Good), plus a scalar over +/// an ARRAY, which the read cannot convert at all. /// Notification () registers raw bytes decoded as the /// DECLARED type, so a width mismatch truncates/over-reads and a same-width float/integer clash /// reinterprets the bit pattern — both at Good. +/// Array tags () are read AND notified as raw bytes, so the +/// element gets the notification rules on both paths. /// /// String-ness is a KIND (TwinCAT-008): a STRING/WSTRING tag matches only a PLC string of the SAME /// kind, at any declared length (0.19.2). A same-width, same-kind numeric divergence @@ -142,6 +148,9 @@ internal static class TwinCATTypeGuard : $"declared {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — a string tag " + $"matches only a PLC {declaredKind.Value.ToString().ToUpperInvariant()} of any length"; } + if (plc.IsArray) + return $"declared scalar {declared} but the PLC symbol is the array {plc.TypeName} ({plc.ByteSize} B) — " + + "author it as an array tag (isArray + arrayLength) or address one element (Arr[i])"; return plc.Kind is TwinCATPlcKind.String or TwinCATPlcKind.WString ? $"declared {declared} but the PLC symbol is the string {plc.TypeName} — declare it String/WString" : null; @@ -157,10 +166,7 @@ internal static class TwinCATTypeGuard if (reason is not null || StringKindOf(declared) is not null) return reason; if (plc.Kind == TwinCATPlcKind.NonScalar) - return $"declared scalar {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — not a scalar" + - (plc.TypeName.StartsWith("ARRAY", StringComparison.OrdinalIgnoreCase) - ? " (author it as an array tag: isArray + arrayLength)" - : string.Empty); + return $"declared scalar {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — not a scalar"; var size = DeclaredByteSize(declared); if (size != plc.ByteSize) 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 e83ced13..eab1c1ad 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 @@ -105,6 +105,26 @@ public sealed class TwinCATTypeGuardTests TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("ST_LOGDATA", 81, DataTypeCategory.Struct)).ShouldNotBeNull(); } + /// + /// A SCALAR tag over a PLC array has no value the symbol-aware read can convert: live on Z33856 + /// (2026-10-07) .ARRFAULTCODE (ARRAY [0..12] OF UINT) read as UInt made the Beckhoff + /// read throw, surfacing as BadCommunicationError and a Degraded driver, while the native path + /// said BadTypeMismatch. Both paths now refuse it with BadTypeMismatch — by category, so an + /// alias of an array type is caught too. Other non-scalars (a pointer read as its address) are left + /// to the read, as before. + /// + [Fact] + public void Read_refuses_a_scalar_declared_over_a_plc_array() + { + TwinCATTypeGuard.CheckRead(TwinCATDataType.UInt, Plc("ARRAY [0..12] OF UINT", 26, DataTypeCategory.Array)) + .ShouldNotBeNull(); + TwinCATTypeGuard.CheckRead(TwinCATDataType.DInt, Plc("T_FAULTS", 52, DataTypeCategory.Array)).ShouldNotBeNull(); + TwinCATTypeGuard.CheckRead(TwinCATDataType.UDInt, Plc("POINTER TO BYTE", 4, DataTypeCategory.Pointer)).ShouldBeNull(); + Plc("ARRAY [0..12] OF UINT", 26, DataTypeCategory.Array).IsArray.ShouldBeTrue(); + Plc("ARRAY [0..12] OF UINT", 26).IsArray.ShouldBeTrue(); // by name when no category resolved + Plc("UINT", 2, DataTypeCategory.Primitive).IsArray.ShouldBeFalse(); + } + /// /// 0.19.2: a STRING / WSTRING tag matches a PLC STRING(n) / WSTRING(n) of ANY length. /// A numeric-over-numeric pair is left to the symbol-aware read, which converts the value.