feat(twincat): expand discovered struct/UDT symbols into addressable member leaves

This commit is contained in:
Joseph Doherty
2026-06-17 20:05:01 -04:00
parent d0a0661f6a
commit 3699fc16a8
3 changed files with 288 additions and 4 deletions
@@ -326,7 +326,12 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var loader = SymbolLoaderFactory.Create(_client, settings);
await Task.Yield(); // honors the async surface; pragmatic given the loader itself is sync
foreach (ISymbol symbol in loader.Symbols)
// Expand struct/UDT/FB-instance symbols down to their addressable atomic members via the
// pure TwinCATSymbolExpander (unit-proven; the real ISymbol surface is non-injectable so the
// recursion is tested through the ITwinCATSymbolNode abstraction, then adapted here). The
// expander dedups by InstancePath, so it also neutralizes any Flat-vs-tree double-listing.
foreach (var ds in TwinCATSymbolExpander.ExpandLeaves(
loader.Symbols.Select(s => new AdsSymbolNode(s))))
{
// ThrowIfCancellationRequested — not yield break — so a cancelled browse propagates
// as OperationCanceledException rather than a silent clean completion. DiscoverAsync
@@ -334,12 +339,33 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// distinctly from a genuine browse failure; a yield break would let a partial
// symbol set appear as a fully successful discovery (Driver.TwinCAT-010).
cancellationToken.ThrowIfCancellationRequested();
var (mapped, arrayLength) = MapSymbolType(symbol.DataType);
var readOnly = !IsSymbolWritable(symbol);
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly, arrayLength);
yield return ds;
}
}
/// <summary>
/// Adapts Beckhoff's <see cref="ISymbol"/> onto <see cref="ITwinCATSymbolNode"/> so the pure
/// <see cref="TwinCATSymbolExpander"/> can walk it. Children are materialized eagerly per node
/// (the loader has already downloaded the whole symbol-info blob, so descending sub-symbols is
/// a local operation). Reuses the existing <see cref="MapSymbolType"/> + <see cref="IsSymbolWritable"/>
/// so atomic-type mapping and access-rights handling stay identical to the pre-expansion path.
/// </summary>
private sealed class AdsSymbolNode(ISymbol symbol) : ITwinCATSymbolNode
{
public string InstancePath => symbol.InstancePath;
public bool IsStruct => symbol.DataType?.Category == DataTypeCategory.Struct;
public (TwinCATDataType? Type, int? ArrayLength) Mapped => MapSymbolType(symbol.DataType);
public IReadOnlyList<ITwinCATSymbolNode> Children =>
symbol.SubSymbols is { } subs
? subs.Select(s => (ITwinCATSymbolNode)new AdsSymbolNode(s)).ToList()
: [];
public bool ReadOnly => !IsSymbolWritable(symbol);
}
/// <summary>
/// Resolves a symbol's <see cref="IDataType"/> to the driver's atomic
/// <see cref="TwinCATDataType"/> plus an optional 1-D array length.
@@ -0,0 +1,96 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Minimal view over an ADS symbol that <see cref="TwinCATSymbolExpander"/> needs to walk a
/// struct/UDT/FB instance down to its addressable atomic leaves. An adapter in
/// <c>AdsTwinCATClient.BrowseSymbolsAsync</c> wraps the real <c>TwinCAT.TypeSystem.ISymbol</c>
/// onto this interface so the recursion is unit-testable without a live ADS target — the real
/// <c>ISymbol</c> / <c>SymbolLoaderFactory</c> surface is non-injectable.
/// </summary>
public interface ITwinCATSymbolNode
{
/// <summary>Full dotted instance path of this symbol (e.g. <c>MAIN.Motor1.Speed</c>).</summary>
string InstancePath { get; }
/// <summary>
/// <c>true</c> when this symbol is a struct/UDT/FB instance (<c>DataType.Category == Struct</c>)
/// and therefore has addressable members under <see cref="Children"/> rather than a value of
/// its own.
/// </summary>
bool IsStruct { get; }
/// <summary>
/// The symbol mapped onto the driver's atomic type surface plus an optional 1-D array length.
/// A <c>null</c> <c>Type</c> means an unsupported atomic leaf (pointer / reference / out-of-scope
/// type) which the expander drops.
/// </summary>
(TwinCATDataType? Type, int? ArrayLength) Mapped { get; }
/// <summary>Struct members (empty for atomic leaves).</summary>
IReadOnlyList<ITwinCATSymbolNode> Children { get; }
/// <summary><c>true</c> when the symbol's access rights forbid writes.</summary>
bool ReadOnly { get; }
}
/// <summary>
/// Pure, dependency-free walker that flattens a forest of <see cref="ITwinCATSymbolNode"/> roots
/// into the addressable atomic leaves the driver can surface as OPC UA variable nodes. A struct
/// node recurses into its <see cref="ITwinCATSymbolNode.Children"/> (depth-guarded); an atomic
/// leaf with a non-null mapped <see cref="TwinCATDataType"/> is yielded as a
/// <see cref="TwinCATDiscoveredSymbol"/>; unsupported leaves (null mapped type) and
/// depth-exceeded nodes are dropped. This is the §2 fix that makes discovered struct/UDT/FB
/// members individually addressable.
/// </summary>
public static class TwinCATSymbolExpander
{
/// <summary>
/// Maximum struct-nesting depth the walker descends before dropping a node. Guards against a
/// pathological / self-referential type graph turning discovery into an unbounded walk. Deep
/// nesting beyond this is exceedingly rare in real PLC code; the operator can still predeclare
/// a member-path tag directly for anything past the floor.
/// </summary>
public const int MaxDepth = 8;
/// <summary>
/// Walk <paramref name="roots"/> to their atomic leaves. A struct node recurses its children
/// (depth-guarded by <see cref="MaxDepth"/>); an atomic leaf with a non-null mapped
/// <see cref="TwinCATDataType"/> is yielded; unsupported leaves (null type) and depth-exceeded
/// nodes are dropped. De-duplicates by <see cref="ITwinCATSymbolNode.InstancePath"/> so any
/// Flat-vs-tree double-listing from the underlying loader collapses to a single emission.
/// </summary>
/// <param name="roots">The top-level symbols (e.g. <c>loader.Symbols</c>) to expand.</param>
/// <returns>One <see cref="TwinCATDiscoveredSymbol"/> per addressable atomic leaf.</returns>
public static IEnumerable<TwinCATDiscoveredSymbol> ExpandLeaves(IEnumerable<ITwinCATSymbolNode> roots)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var root in roots)
foreach (var leaf in Walk(root, depth: 0, seen))
yield return leaf;
}
private static IEnumerable<TwinCATDiscoveredSymbol> Walk(
ITwinCATSymbolNode node, int depth, HashSet<string> seen)
{
// Depth guard: drop a node whose nesting exceeds the floor rather than recursing further.
if (depth >= MaxDepth) yield break;
if (node.IsStruct)
{
// A struct contributes no value of its own — recurse its members. (Empty children =>
// nothing emitted, which is the correct "no addressable atomic" outcome.)
foreach (var child in node.Children)
foreach (var leaf in Walk(child, depth + 1, seen))
yield return leaf;
yield break;
}
var (type, arrayLength) = node.Mapped;
if (type is null) yield break; // unsupported atomic leaf (pointer / out-of-scope type) — drop.
// Dedup by InstancePath — neutralizes any Flat-vs-tree double-listing of the same leaf.
if (!seen.Add(node.InstancePath)) yield break;
yield return new TwinCATDiscoveredSymbol(node.InstancePath, type, node.ReadOnly, arrayLength);
}
}