fix(twincat): map TwinCAT 2 ADS base-type names in symbol browse

A TC2 PLC reports top-level integer globals as INT16/UINT16 (354 symbols on
the Elma cleaner Z33856) and the Beckhoff type system keeps that spelling as
a built-in primitive, so MapSymbolTypeName returned null and discovery dropped
them silently. INT16/UINT16/INT32/UINT32/UINT8/FLOAT/DOUBLE now map like their
IEC twins; padded member names ("REAL ") are trimmed; a parenthesised
non-string form ("INT (VAR_IN_OUT)", a reference) stays unsupported.
This commit is contained in:
Joseph Doherty
2026-10-07 13:59:40 -04:00
parent b7b754fd98
commit 1a74c3c2e0
2 changed files with 80 additions and 11 deletions
@@ -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,
@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Pins <c>AdsTwinCATClient.MapSymbolTypeName</c> — 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.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATSymbolTypeNameTests
{
/// <summary>
/// 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
/// <c>INT16</c> and 121 as <c>UINT16</c> (e.g. <c>.ACTUALOPERATIONMODE</c>). The Beckhoff
/// type system carries these as built-in primitives (<c>AdsSymbolProvider.CreateBuildInTypes</c>:
/// INT16, UINT16, INT32, UINT32, UINT8, FLOAT, DOUBLE), so <c>IDataType.Name</c> arrives spelled
/// that way and must map like its IEC twin.
/// </summary>
/// <param name="typeName">The type name as the PLC / Beckhoff type system reports it.</param>
/// <param name="expected">The driver atomic type it must map to.</param>
[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);
/// <summary>
/// The same TC2 data-type upload spells struct members with a trailing blank — <c>"REAL "</c>
/// (54 members on Z33856) and <c>"DINT "</c> (24) — so the name is trimmed before matching.
/// </summary>
/// <param name="typeName">A padded type name.</param>
/// <param name="expected">The driver atomic type it must map to.</param>
[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);
/// <summary>The IEC spellings, sized strings and unsupported names are unchanged.</summary>
[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();
}
}