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();
+ }
+}