diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
index 5104cde..1b370f3 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
@@ -272,12 +272,50 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
foreach (ISymbol symbol in loader.Symbols)
{
if (cancellationToken.IsCancellationRequested) yield break;
- var mapped = MapSymbolTypeName(symbol.DataType?.Name);
+ var mapped = ResolveSymbolDataType(symbol.DataType);
var readOnly = !IsSymbolWritable(symbol);
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
}
}
+ ///
+ /// Resolve an IEC atomic for a TwinCAT symbol's data type.
+ /// ENUMs surface as their underlying integer (the enum's BaseType); ALIAS chains
+ /// are walked recursively via until an atomic primitive
+ /// is reached. POINTER / REFERENCE / INTERFACE / UNION / STRUCT / FB / array types remain
+ /// out of scope and surface as null so the caller skips them.
+ ///
+ ///
+ /// Recursion is bounded at as a defence against pathological
+ /// cycles in the type graph — TwinCAT shouldn't emit those, but this is cheap insurance.
+ ///
+ internal const int MaxAliasDepth = 16;
+
+ internal static TwinCATDataType? ResolveSymbolDataType(IDataType? dataType)
+ {
+ var current = dataType;
+ for (var depth = 0; current is not null && depth < MaxAliasDepth; depth++)
+ {
+ switch (current.Category)
+ {
+ case DataTypeCategory.Primitive:
+ case DataTypeCategory.String:
+ return MapSymbolTypeName(current.Name);
+ case DataTypeCategory.Enum:
+ case DataTypeCategory.Alias:
+ // IEnumType : IAliasType, so BaseType walk handles both. For an enum the
+ // base type is the underlying integer; for alias chains it's the next link.
+ if (current is IAliasType alias) { current = alias.BaseType; continue; }
+ return null;
+ default:
+ // POINTER / REFERENCE / INTERFACE / UNION / STRUCT / ARRAY / FB / Program —
+ // explicitly out of scope at this PR.
+ return null;
+ }
+ }
+ return null;
+ }
+
private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch
{
"BOOL" or "BIT" => TwinCATDataType.Bool,
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeResolutionTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeResolutionTests.cs
new file mode 100644
index 0000000..aa7952c
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeResolutionTests.cs
@@ -0,0 +1,136 @@
+using Shouldly;
+using TwinCAT.Ads.TypeSystem;
+using TwinCAT.TypeSystem;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///
+/// Coverage for — the discovery
+/// helper that maps a TwinCAT to a driver .
+/// PR-1.5 added ENUM and ALIAS chain handling on top of the original primitive name match.
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATTypeResolutionTests
+{
+ [Fact]
+ public void Primitive_resolves_directly()
+ {
+ var dt = new PrimitiveType("DINT", typeof(int));
+ AdsTwinCATClient.ResolveSymbolDataType(dt).ShouldBe(TwinCATDataType.DInt);
+ }
+
+ [Fact]
+ public void Null_data_type_returns_null()
+ {
+ AdsTwinCATClient.ResolveSymbolDataType(null).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Single_alias_resolves_to_underlying_atomic()
+ {
+ var primitive = new PrimitiveType("INT", typeof(short));
+ var alias = new AliasType("DegreesC", primitive);
+
+ AdsTwinCATClient.ResolveSymbolDataType(alias).ShouldBe(TwinCATDataType.Int);
+ }
+
+ [Fact]
+ public void Chained_alias_resolves_through_multiple_links()
+ {
+ var primitive = new PrimitiveType("DINT", typeof(int));
+ var inner = new AliasType("Inner", primitive);
+ var outer = new AliasType("Outer", inner);
+
+ AdsTwinCATClient.ResolveSymbolDataType(outer).ShouldBe(TwinCATDataType.DInt);
+ }
+
+ [Fact]
+ public void Alias_to_unknown_primitive_returns_null()
+ {
+ // A primitive whose IEC name we don't recognise should drop through cleanly. Pick a CLR
+ // type with a known marshal size so PrimitiveType's ctor accepts it; the name is what
+ // exercises the unknown-type branch in MapSymbolTypeName.
+ var primitive = new PrimitiveType("MYSTERY", typeof(int));
+ var alias = new AliasType("Wrap", primitive);
+
+ AdsTwinCATClient.ResolveSymbolDataType(alias).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Enum_resolves_to_underlying_integer_base_type()
+ {
+ var underlying = new PrimitiveType("INT", typeof(short));
+ var enumType = new TestEnumType("EState", underlying);
+
+ AdsTwinCATClient.ResolveSymbolDataType(enumType).ShouldBe(TwinCATDataType.Int);
+ }
+
+ [Fact]
+ public void Enum_with_dint_base_resolves_to_DInt()
+ {
+ var underlying = new PrimitiveType("DINT", typeof(int));
+ var enumType = new TestEnumType("EBigFlags", underlying);
+
+ AdsTwinCATClient.ResolveSymbolDataType(enumType).ShouldBe(TwinCATDataType.DInt);
+ }
+
+ [Fact]
+ public void Cyclic_alias_chain_terminates_at_depth_cap()
+ {
+ // Pathological: a self-referential alias. The resolver must give up rather than spin.
+ var loop = new SelfReferentialAlias();
+ AdsTwinCATClient.ResolveSymbolDataType(loop).ShouldBeNull();
+ }
+
+ ///
+ /// Minimal in-test stub for . Only Category and
+ /// BaseType are exercised by ;
+ /// the rest of the surface throws to flag any accidental dependence in future tests.
+ ///
+ private sealed class TestEnumType(string name, IDataType baseType) : IDataType, IAliasType
+ {
+ public string Name { get; } = name;
+ public IDataType BaseType { get; } = baseType;
+ public DataTypeCategory Category => DataTypeCategory.Enum;
+ public string BaseTypeName => BaseType.Name;
+ public string FullName => Name;
+ public string Namespace => string.Empty;
+ public int Id => 0;
+ public string Comment => string.Empty;
+ public ITypeAttributeCollection Attributes => throw new NotSupportedException();
+ public bool IsContainer => false;
+ public bool IsPointer => false;
+ public bool IsReference => false;
+ public bool IsPrimitive => false;
+ public int Size => BaseType.Size;
+ public int ByteSize => BaseType.ByteSize;
+ public int BitSize => BaseType.BitSize;
+ public bool IsBitType => false;
+ public bool IsByteAligned => true;
+ }
+
+ /// Self-referential alias — exercises the depth cap.
+ private sealed class SelfReferentialAlias : IDataType, IAliasType
+ {
+ public string Name => "Loop";
+ public IDataType BaseType => this;
+ public DataTypeCategory Category => DataTypeCategory.Alias;
+ public string BaseTypeName => Name;
+ public string FullName => Name;
+ public string Namespace => string.Empty;
+ public int Id => 0;
+ public string Comment => string.Empty;
+ public ITypeAttributeCollection Attributes => throw new NotSupportedException();
+ public bool IsContainer => false;
+ public bool IsPointer => false;
+ public bool IsReference => false;
+ public bool IsPrimitive => false;
+ public int Size => 0;
+ public int ByteSize => 0;
+ public int BitSize => 0;
+ public bool IsBitType => false;
+ public bool IsByteAligned => true;
+ }
+}