From 0df14ab94ac5e68da854977fb82634516631024e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 17:48:45 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20twincat-1.5=20=E2=80=94=20ENUM/ALIAS=20?= =?UTF-8?q?discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve TwinCAT symbol data types via the IDataType chain instead of a flat name match. ALIAS chains walk BaseType recursively (depth-capped at 16 against pathological cycles); ENUM surfaces its underlying integer base type. POINTER / REFERENCE / INTERFACE / UNION / STRUCT / ARRAY / FB remain explicitly out of scope and surface as null. Closes #309 --- .../AdsTwinCATClient.cs | 40 +++++- .../TwinCATTypeResolutionTests.cs | 136 ++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeResolutionTests.cs 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; + } +} -- 2.49.1