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 385b37e6..245ce4c8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -376,27 +376,36 @@ internal sealed class AdsTwinCATClient : ITwinCATClient return (MapSymbolTypeName(dataType.Name), null); } - private static TwinCATDataType? MapSymbolTypeName(string? typeName) + internal static TwinCATDataType? MapSymbolTypeName(string? typeName) { if (typeName is null) return null; + // A TwinCAT 2 data-type upload pads some member type names with a trailing blank + // ("REAL ", "DINT " on a TC 2.11 CX) — trim before matching. + var name = typeName.Trim(); // SymbolLoader emits STRING(80) / WSTRING(80) with the declared bound baked into // the type name — strip the "(...)" suffix so sized strings map onto the bare - // String/WString atom the driver speaks. - var paren = typeName.IndexOf('('); - var bare = paren > 0 ? typeName[..paren] : typeName; + // String/WString atom the driver speaks. Only for the string kinds: any other + // parenthesised form ("INT (VAR_IN_OUT)" — a 4-byte reference on TC2) is not the + // scalar its prefix names and stays unsupported. + var paren = name.IndexOf('('); + var bare = paren > 0 ? name[..paren] : name; + if (paren > 0 && bare is not ("STRING" or "WSTRING")) return null; return bare switch { "BOOL" or "BIT" => TwinCATDataType.Bool, "SINT" or "BYTE" => TwinCATDataType.SInt, - "USINT" => TwinCATDataType.USInt, - "INT" or "WORD" => TwinCATDataType.Int, - "UINT" => TwinCATDataType.UInt, - "DINT" or "DWORD" => TwinCATDataType.DInt, - "UDINT" => TwinCATDataType.UDInt, + // TwinCAT 2 reports top-level globals by their ADS base-type name (INT16 / UINT16 / …), + // and the Beckhoff type system keeps that spelling as a built-in primitive + // (AdsSymbolProvider.CreateBuildInTypes) — map each like its IEC twin. + "USINT" or "UINT8" => TwinCATDataType.USInt, + "INT" or "WORD" or "INT16" => TwinCATDataType.Int, + "UINT" or "UINT16" => TwinCATDataType.UInt, + "DINT" or "DWORD" or "INT32" => TwinCATDataType.DInt, + "UDINT" or "UINT32" => TwinCATDataType.UDInt, "LINT" or "LWORD" => TwinCATDataType.LInt, "ULINT" => TwinCATDataType.ULInt, - "REAL" => TwinCATDataType.Real, - "LREAL" => TwinCATDataType.LReal, + "REAL" or "FLOAT" => TwinCATDataType.Real, + "LREAL" or "DOUBLE" => TwinCATDataType.LReal, "STRING" => TwinCATDataType.String, "WSTRING" => TwinCATDataType.WString, "TIME" => TwinCATDataType.Time, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolTypeNameTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolTypeNameTests.cs new file mode 100644 index 00000000..177bc036 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolTypeNameTests.cs @@ -0,0 +1,60 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; + +/// +/// Pins AdsTwinCATClient.MapSymbolTypeName — the symbol-browse type mapper — against the type +/// names a real TwinCAT 2 PLC reports. A name the mapper does not know drops the symbol from +/// discovery silently, so every TC2 spelling below was a symbol the browse never offered. +/// +[Trait("Category", "Unit")] +public sealed class TwinCATSymbolTypeNameTests +{ + /// + /// TwinCAT 2 reports top-level integer globals by their ADS base-type name, not the IEC name: + /// the Elma cleaner Z33856 (TC 2.11, symbol upload 2026-09-24) lists 233 symbols as + /// INT16 and 121 as UINT16 (e.g. .ACTUALOPERATIONMODE). The Beckhoff + /// type system carries these as built-in primitives (AdsSymbolProvider.CreateBuildInTypes: + /// INT16, UINT16, INT32, UINT32, UINT8, FLOAT, DOUBLE), so IDataType.Name arrives spelled + /// that way and must map like its IEC twin. + /// + /// The type name as the PLC / Beckhoff type system reports it. + /// The driver atomic type it must map to. + [Theory] + [InlineData("INT16", TwinCATDataType.Int)] + [InlineData("UINT16", TwinCATDataType.UInt)] + [InlineData("INT32", TwinCATDataType.DInt)] + [InlineData("UINT32", TwinCATDataType.UDInt)] + [InlineData("UINT8", TwinCATDataType.USInt)] + [InlineData("FLOAT", TwinCATDataType.Real)] + [InlineData("DOUBLE", TwinCATDataType.LReal)] + public void TwinCAT2_ads_base_type_names_map_like_their_iec_twins(string typeName, TwinCATDataType expected) + => AdsTwinCATClient.MapSymbolTypeName(typeName).ShouldBe(expected); + + /// + /// The same TC2 data-type upload spells struct members with a trailing blank — "REAL " + /// (54 members on Z33856) and "DINT " (24) — so the name is trimmed before matching. + /// + /// A padded type name. + /// The driver atomic type it must map to. + [Theory] + [InlineData("REAL ", TwinCATDataType.Real)] + [InlineData("DINT ", TwinCATDataType.DInt)] + [InlineData(" STRING(80) ", TwinCATDataType.String)] + public void Padded_type_names_are_trimmed(string typeName, TwinCATDataType expected) + => AdsTwinCATClient.MapSymbolTypeName(typeName).ShouldBe(expected); + + /// The IEC spellings, sized strings and unsupported names are unchanged. + [Fact] + public void Existing_iec_mappings_are_unchanged() + { + AdsTwinCATClient.MapSymbolTypeName("INT").ShouldBe(TwinCATDataType.Int); + AdsTwinCATClient.MapSymbolTypeName("STRING(8)").ShouldBe(TwinCATDataType.String); + AdsTwinCATClient.MapSymbolTypeName("WSTRING(20)").ShouldBe(TwinCATDataType.WString); + AdsTwinCATClient.MapSymbolTypeName("ST_LOGDATA").ShouldBeNull(); + AdsTwinCATClient.MapSymbolTypeName("INT (VAR_IN_OUT)").ShouldBeNull(); + AdsTwinCATClient.MapSymbolTypeName(null).ShouldBeNull(); + } +}