[twincat] TwinCAT — Nested UDT browse via online type walker #396
@@ -28,6 +28,56 @@ sessions. Pick one:
|
||||
The CLI compiles + runs without a router, but every wire call fails with a
|
||||
transport error until one is reachable.
|
||||
|
||||
## UDT decomposition
|
||||
|
||||
PR 4.1 (issue #315) replaces the old "skip non-atomic symbols" behaviour
|
||||
of `BrowseSymbolsAsync` with a recursive type walker
|
||||
(`TwinCATTypeWalker`). When the OtOpcUa server's TwinCAT driver runs
|
||||
discovery with `EnableControllerBrowse=true`, struct / UDT / function-block
|
||||
typed symbols flatten into one OPC UA variable per atomic leaf. Browse
|
||||
addresses use the same dotted-instance form the PLC exposes:
|
||||
|
||||
| PLC declaration | OPC UA browse paths surfaced |
|
||||
|---|---|
|
||||
| `MAIN.bStart : BOOL` | `MAIN.bStart` |
|
||||
| `GVL.stMotor : ST_Motor` | `GVL.stMotor.bRunning`, `GVL.stMotor.nState`, `GVL.stMotor.rTemperature`, … |
|
||||
| `GVL.aRecipe : ARRAY[1..10] OF DINT` | `GVL.aRecipe[1]` … `GVL.aRecipe[10]` |
|
||||
| `GVL.aPairs : ARRAY[0..2] OF ST_Pair` | `GVL.aPairs[0].nCount`, `GVL.aPairs[0].rValue`, `GVL.aPairs[1].…` |
|
||||
| `GVL.aBig : ARRAY[1..5000] OF DINT` | `GVL.aBig` (single whole-array root — over the cap) |
|
||||
|
||||
The CLI's `read` / `write` / `subscribe` commands take dotted paths
|
||||
directly:
|
||||
|
||||
```powershell
|
||||
# Read a struct member
|
||||
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.stMotor.rTemperature -t Real
|
||||
|
||||
# Read an array element
|
||||
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s "GVL.aRecipe[3]" -t DInt
|
||||
```
|
||||
|
||||
### Array expansion bound
|
||||
|
||||
`TwinCATDriverOptions.MaxArrayExpansion` (default `1024`) caps how many
|
||||
elements an array contributes to the discovered address space. Arrays
|
||||
whose total element count exceeds the cap surface as a single
|
||||
whole-array root with `IsArrayRoot=true` instead of one variable per
|
||||
element. Raise the bound when operators routinely care about individual
|
||||
elements of large recipe / lookup tables; lower it to keep discovery
|
||||
cheap for symbol tables that ship multi-thousand-element scratch
|
||||
arrays. Pre-declared whole-array tags from the `Tags` config bypass the
|
||||
walker entirely — set `ArrayDimensions` on a `TwinCATTagDefinition` to
|
||||
keep array reads on the existing PR 1.4 read-array path.
|
||||
|
||||
### Cycle / depth guard
|
||||
|
||||
The walker tracks the visited-type set + a hard depth cap of 8 levels
|
||||
so a self-pointer (`POINTER TO ST_Self`) or pathological alias chain
|
||||
terminates rather than spinning. POINTER / REFERENCE members are
|
||||
skipped at the type-graph level — surfacing them would require
|
||||
dereferencing through the AMS routing layer which has its own access
|
||||
patterns.
|
||||
|
||||
## Common flags
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|
||||
@@ -57,6 +57,14 @@ All three gated on `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID` env
|
||||
vars; skip cleanly via `[TwinCATFact]` when the VM isn't reachable or
|
||||
vars are unset.
|
||||
|
||||
PR 4.1 / #315 adds `TwinCATUdtBrowseTests.Driver_browses_UDT_tree_and_flattens_to_atomic_leaves`
|
||||
which exercises `TwinCATDriver.DiscoverAsync` end-to-end against the
|
||||
`GVL_Plant` UDT fixture. Asserts the discovery surface emits one OPC UA
|
||||
variable per atomic leaf and folds `aAlarmRecords[1..2000]` into a
|
||||
single `IsArrayRoot` placeholder when the element count exceeds the
|
||||
default 1024-element cap (UDT per-member coverage; see
|
||||
`TwinCatProject/README.md §Complex hierarchy` for the supporting DUTs).
|
||||
|
||||
### Unit
|
||||
|
||||
- `TwinCATAmsAddressTests` — `ads://<netId>:<port>` parsing + routing
|
||||
@@ -66,6 +74,14 @@ vars are unset.
|
||||
- `TwinCATSymbolPathTests` — symbol-path routing for nested struct members
|
||||
- `TwinCATSymbolBrowserTests` — `ITagDiscovery.DiscoverAsync` via
|
||||
`ReadSymbolsAsync` (#188) + system-symbol filtering
|
||||
- `TwinCATTypeWalkerTests` — PR 4.1 / #315 nested-UDT decomposition:
|
||||
atomic / single-level struct / nested struct / array-of-atomic
|
||||
(in / over `MaxArrayExpansion`) / array-of-struct / alias chain /
|
||||
pointer skip / self-referencing struct depth-cap / per-leaf
|
||||
`MaxArrayExpansion` honored / ReadOnly propagation. Stub `IDataType`
|
||||
/ `IStructType` / `IArrayType` / `IMember` / `IDimensionCollection`
|
||||
trees built in-test so the walker is exercised without
|
||||
`Beckhoff.TwinCAT.Ads`-internal ctors.
|
||||
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` (#189)
|
||||
registration, callback-delivery-to-`OnDataChange` wiring, unregister on
|
||||
unsubscribe
|
||||
|
||||
@@ -485,21 +485,39 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
|
||||
int maxArrayExpansion,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// SymbolLoaderFactory downloads the symbol-info blob once then iterates locally — the
|
||||
// async surface on this interface is for our callers, not for the underlying call which
|
||||
// is effectively sync on top of the already-open AdsClient.
|
||||
var settings = new SymbolLoaderSettings(SymbolsLoadMode.Flat);
|
||||
//
|
||||
// PR 4.1 / #315 — switched from VirtualView-flat (top-level only) to VirtualTree so the
|
||||
// loader hands us each top-level symbol with its full IDataType graph wired up. We then
|
||||
// run TwinCATTypeWalker against each root to flatten structs / arrays into per-leaf
|
||||
// entries. Atomic top-level symbols still surface as a single leaf — the walker treats
|
||||
// a primitive root as a 1-leaf walk.
|
||||
var settings = new SymbolLoaderSettings(SymbolsLoadMode.VirtualTree);
|
||||
var loader = SymbolLoaderFactory.Create(_client, settings);
|
||||
await Task.Yield(); // honors the async surface; pragmatic given the loader itself is sync
|
||||
|
||||
// The walker only needs MaxArrayExpansion off TwinCATDriverOptions — synthesise a
|
||||
// throwaway options instance with just that field set so the walker doesn't gain a
|
||||
// direct dependency on TwinCATDriverOptions.
|
||||
var walkerOptions = new TwinCATDriverOptions { MaxArrayExpansion = Math.Max(1, maxArrayExpansion) };
|
||||
|
||||
foreach (ISymbol symbol in loader.Symbols)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) yield break;
|
||||
var mapped = ResolveSymbolDataType(symbol.DataType);
|
||||
var readOnly = !IsSymbolWritable(symbol);
|
||||
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
|
||||
foreach (var leaf in TwinCATTypeWalker.Walk(
|
||||
symbol.DataType, symbol.InstancePath, offsetRoot: 0, readOnly, walkerOptions))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) yield break;
|
||||
yield return new TwinCATDiscoveredSymbol(
|
||||
leaf.InstancePath, leaf.AtomicType, leaf.ReadOnly,
|
||||
leaf.IsArrayRoot, leaf.ArrayLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,13 +115,22 @@ public interface ITwinCATClient : IDisposable
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Walk the target's symbol table via the TwinCAT <c>SymbolLoaderFactory</c> (flat mode).
|
||||
/// Yields each top-level symbol the PLC exposes — global variables, program-scope locals,
|
||||
/// function-block instance fields. Filters for our atomic type surface; structured /
|
||||
/// UDT / function-block typed symbols surface with <c>DataType = null</c> so callers can
|
||||
/// decide whether to drill in via their own walker.
|
||||
/// Walk the target's symbol table via the TwinCAT <c>SymbolLoaderFactory</c> (flat mode)
|
||||
/// and yield one <see cref="TwinCATDiscoveredSymbol"/> per atomic leaf. PR 4.1 / #315
|
||||
/// extended this to recurse into struct / UDT members and array elements via
|
||||
/// <see cref="TwinCATTypeWalker"/> so callers see <c>MyStruct.Inner.Field</c> /
|
||||
/// <c>aTags[3]</c> rows instead of the parent symbol with <c>DataType = null</c>.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken);
|
||||
/// <param name="maxArrayExpansion">
|
||||
/// Upper bound on per-element expansion for array-typed members. Arrays whose element
|
||||
/// count exceeds this value surface as a single whole-array root with
|
||||
/// <see cref="TwinCATDiscoveredSymbol.IsArrayRoot"/> set instead of N individual
|
||||
/// <c>Path[i]</c> rows.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancels the enumeration; in-flight loader downloads still complete.</param>
|
||||
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
|
||||
int maxArrayExpansion,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// PR 2.2 — wipe process-scoped optional caches (today: the ADS variable-handle
|
||||
@@ -139,16 +148,28 @@ public interface ITwinCATNotificationHandle : IDisposable { }
|
||||
|
||||
/// <summary>
|
||||
/// One symbol yielded by <see cref="ITwinCATClient.BrowseSymbolsAsync"/> — full instance
|
||||
/// path + detected <see cref="TwinCATDataType"/> + read-only flag.
|
||||
/// path + detected <see cref="TwinCATDataType"/> + read-only flag. PR 4.1 / #315 added
|
||||
/// <see cref="IsArrayRoot"/> + <see cref="ArrayLength"/> so the discovery layer can
|
||||
/// surface arrays that exceeded <see cref="TwinCATDriverOptions.MaxArrayExpansion"/>
|
||||
/// as whole-array tags instead of per-element entries.
|
||||
/// </summary>
|
||||
/// <param name="InstancePath">Full dotted symbol path (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>).</param>
|
||||
/// <param name="DataType">Mapped <see cref="TwinCATDataType"/>; <c>null</c> when the symbol's type
|
||||
/// doesn't map onto our supported atomic surface (UDTs, pointers, function blocks).</param>
|
||||
/// <param name="InstancePath">Full dotted symbol path (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>,
|
||||
/// <c>MyStruct.Inner.Field</c>, <c>aTags[3]</c>).</param>
|
||||
/// <param name="DataType">Mapped <see cref="TwinCATDataType"/>; <c>null</c> when the symbol's
|
||||
/// type doesn't map onto our supported atomic surface (struct-typed array root over the
|
||||
/// expansion cap, pointer / reference / function-block instance).</param>
|
||||
/// <param name="ReadOnly"><c>true</c> when the symbol's AccessRights flag forbids writes.</param>
|
||||
/// <param name="IsArrayRoot"><c>true</c> when the symbol represents an array whose element count
|
||||
/// exceeded <see cref="TwinCATDriverOptions.MaxArrayExpansion"/>; the caller should surface
|
||||
/// it as a whole-array tag rather than per-element.</param>
|
||||
/// <param name="ArrayLength">Element count when <see cref="IsArrayRoot"/> is <c>true</c>;
|
||||
/// <c>null</c> otherwise.</param>
|
||||
public sealed record TwinCATDiscoveredSymbol(
|
||||
string InstancePath,
|
||||
TwinCATDataType? DataType,
|
||||
bool ReadOnly);
|
||||
bool ReadOnly,
|
||||
bool IsArrayRoot = false,
|
||||
int? ArrayLength = null);
|
||||
|
||||
/// <summary>Factory for <see cref="ITwinCATClient"/>s. One client per device.</summary>
|
||||
public interface ITwinCATClientFactory
|
||||
|
||||
@@ -408,23 +408,42 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
|
||||
// Controller-side symbol browse — opt-in. Falls back to pre-declared-only on any
|
||||
// client-side error so a flaky symbol-table download doesn't block discovery.
|
||||
//
|
||||
// PR 4.1 / #315 — the symbol stream now contains one entry per atomic leaf rather
|
||||
// than per top-level symbol. Dotted paths (`MyStruct.Inner.Field`) split into
|
||||
// nested folders so the OPC UA browse tree mirrors the PLC's UDT hierarchy. Array
|
||||
// elements (`aTags[3]`) stay as variable leaves under the parent folder — they
|
||||
// don't get an extra folder level because that would explode the tree for every
|
||||
// array element while adding no useful navigation.
|
||||
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
|
||||
{
|
||||
IAddressSpaceBuilder? discoveredFolder = null;
|
||||
// Cached per-path folder builders so a struct with N members shares the parent
|
||||
// folder rather than creating it N times. Keyed on the full dotted prefix.
|
||||
var folderCache = new Dictionary<string, IAddressSpaceBuilder>(StringComparer.Ordinal);
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
await foreach (var sym in client.BrowseSymbolsAsync(cancellationToken).ConfigureAwait(false))
|
||||
await foreach (var sym in client.BrowseSymbolsAsync(_options.MaxArrayExpansion, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (TwinCATSystemSymbolFilter.IsSystemSymbol(sym.InstancePath)) continue;
|
||||
if (sym.DataType is not TwinCATDataType dt) continue; // unsupported type
|
||||
|
||||
// Array root over the expansion cap surfaces with IsArrayRoot=true; the
|
||||
// element type may or may not be atomic. Skip unless the element is
|
||||
// atomic — there's no useful read shape for a struct-of-N>cap today.
|
||||
if (sym.IsArrayRoot && sym.DataType is null) continue;
|
||||
if (sym.DataType is not TwinCATDataType dt) continue; // unsupported leaf type
|
||||
|
||||
discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered");
|
||||
discoveredFolder.Variable(sym.InstancePath, sym.InstancePath, new DriverAttributeInfo(
|
||||
var (parentBuilder, leafName) = ResolveLeafFolder(
|
||||
discoveredFolder, sym.InstancePath, folderCache);
|
||||
|
||||
parentBuilder.Variable(leafName, leafName, new DriverAttributeInfo(
|
||||
FullName: sym.InstancePath,
|
||||
DriverDataType: dt.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
IsArray: sym.IsArrayRoot,
|
||||
ArrayDim: sym.IsArrayRoot && sym.ArrayLength is int n && n > 0
|
||||
? (uint)n : null,
|
||||
SecurityClass: sym.ReadOnly
|
||||
? SecurityClassification.ViewOnly
|
||||
: SecurityClassification.Operate,
|
||||
@@ -443,6 +462,48 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 4.1 / #315 — split a dotted instance path into (parent folder, leaf segment).
|
||||
/// Each interior segment maps to a folder; the final segment is the variable name.
|
||||
/// Folders are cached so two members of the same struct share the parent. Array
|
||||
/// subscripts on interior segments (e.g. <c>aMotors[0].Status.Running</c>) keep the
|
||||
/// bracketed segment as a single folder name — the OPC UA browse name preserves the
|
||||
/// array-element identifier.
|
||||
/// </summary>
|
||||
internal static (IAddressSpaceBuilder Parent, string LeafName) ResolveLeafFolder(
|
||||
IAddressSpaceBuilder root, string instancePath,
|
||||
Dictionary<string, IAddressSpaceBuilder> folderCache)
|
||||
{
|
||||
var lastDot = instancePath.LastIndexOf('.');
|
||||
if (lastDot < 0)
|
||||
return (root, instancePath); // top-level — no parent folder
|
||||
|
||||
var prefix = instancePath.Substring(0, lastDot);
|
||||
var leaf = instancePath.Substring(lastDot + 1);
|
||||
|
||||
if (folderCache.TryGetValue(prefix, out var cached))
|
||||
return (cached, leaf);
|
||||
|
||||
// Walk segment-by-segment, materialising each folder once. Reuse cached prefixes so
|
||||
// sibling members share the same parent.
|
||||
var segments = prefix.Split('.');
|
||||
var current = root;
|
||||
var sb = new System.Text.StringBuilder();
|
||||
foreach (var seg in segments)
|
||||
{
|
||||
if (sb.Length > 0) sb.Append('.');
|
||||
sb.Append(seg);
|
||||
var key = sb.ToString();
|
||||
if (!folderCache.TryGetValue(key, out var folder))
|
||||
{
|
||||
folder = current.Folder(seg, seg);
|
||||
folderCache[key] = folder;
|
||||
}
|
||||
current = folder;
|
||||
}
|
||||
return (current, leaf);
|
||||
}
|
||||
|
||||
// ---- ISubscribable (native ADS notifications with poll fallback) ----
|
||||
|
||||
private readonly ConcurrentDictionary<long, NativeSubscription> _nativeSubs = new();
|
||||
|
||||
@@ -32,6 +32,18 @@ public sealed class TwinCATDriverOptions
|
||||
/// the strict-config path for deployments where only declared tags should appear.
|
||||
/// </summary>
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR 4.1 / #315 — upper bound on per-element array expansion during nested-UDT browse.
|
||||
/// Arrays whose total <c>ElementCount</c> exceeds this value surface as a single
|
||||
/// whole-array root leaf instead of N individual <c>Path[i]</c> entries; that keeps
|
||||
/// pathological multi-thousand-element arrays from inflating the discovered address
|
||||
/// space. The default of <c>1024</c> covers typical recipe / lookup tables; raise it
|
||||
/// when the PLC routinely ships larger arrays whose individual elements are operationally
|
||||
/// interesting, lower it to keep discovery cheap. Has no effect on whole-array tags
|
||||
/// declared in <see cref="Tags"/> (those bypass the walker entirely).
|
||||
/// </summary>
|
||||
public int MaxArrayExpansion { get; init; } = 1024;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
233
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeWalker.cs
Normal file
233
src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeWalker.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
using TwinCAT.TypeSystem;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// PR 4.1 / #315 — pure helper that walks a TwinCAT <see cref="IDataType"/> tree starting at
|
||||
/// a top-level instance and yields one <see cref="DiscoveredLeaf"/> per atomic leaf along
|
||||
/// the dotted instance path. Lets <see cref="AdsTwinCATClient.BrowseSymbolsAsync"/> expose
|
||||
/// UDT members as individually-readable OPC UA variable nodes instead of dropping the whole
|
||||
/// symbol the way PR 1.5 did.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Scope</b> — IEC primitives + <c>STRING</c>/<c>WSTRING</c> + IEC time/date types
|
||||
/// resolve to a leaf; aliases recurse into their base type; structs recurse into each
|
||||
/// member; arrays expand element-by-element up to <see cref="TwinCATDriverOptions.MaxArrayExpansion"/>;
|
||||
/// pointers / references / unions / function-block instances / interfaces are skipped.</para>
|
||||
///
|
||||
/// <para><b>Recursion guard</b> — the walker tracks visited <see cref="IDataType"/> instances
|
||||
/// plus a hard depth cap (<see cref="MaxDepth"/>) so a self-pointer or cyclic typedef
|
||||
/// terminates rather than blowing the stack.</para>
|
||||
///
|
||||
/// <para><b>Array policy</b> — when an array's <c>ElementCount > MaxArrayExpansion</c>
|
||||
/// a single leaf is emitted with <see cref="DiscoveredLeaf.IsArrayRoot"/> set so the
|
||||
/// caller can surface the array root as a whole-array tag (the existing 1.4 read-path
|
||||
/// handles that). Arrays-of-structs follow the same cap — over the limit, only the
|
||||
/// root surfaces.</para>
|
||||
///
|
||||
/// <para><b>Offset</b> — every leaf carries the cumulative byte offset from the symbol
|
||||
/// root. Not used by the current discovery path (which still resolves writes/reads by
|
||||
/// symbolic name) but PR 4.x will consume it for offset-based bulk reads.</para>
|
||||
/// </remarks>
|
||||
internal static class TwinCATTypeWalker
|
||||
{
|
||||
/// <summary>Hard recursion cap — kicks in before <see cref="System.StackOverflowException"/>.</summary>
|
||||
internal const int MaxDepth = 8;
|
||||
|
||||
/// <summary>
|
||||
/// One atomic leaf yielded by <see cref="Walk"/>. Either an atomic-typed scalar / array
|
||||
/// element (<see cref="IsArrayRoot"/> = false, <see cref="AtomicType"/> non-null) or a
|
||||
/// "too-large array" placeholder where the element count exceeded
|
||||
/// <see cref="TwinCATDriverOptions.MaxArrayExpansion"/> (<see cref="IsArrayRoot"/> = true
|
||||
/// + <see cref="ArrayLength"/> set).
|
||||
/// </summary>
|
||||
/// <param name="InstancePath">Dotted symbol path (e.g. <c>MAIN.Recipe.Step</c>, <c>GVL.Tags[3]</c>).</param>
|
||||
/// <param name="AtomicType">Mapped atomic <see cref="TwinCATDataType"/>; <c>null</c> only when
|
||||
/// <see cref="IsArrayRoot"/> is <c>true</c> and the element type itself is a struct.</param>
|
||||
/// <param name="Offset">Byte offset of this leaf relative to the walked root.</param>
|
||||
/// <param name="ReadOnly"><c>true</c> when the parent symbol forbids writes.</param>
|
||||
/// <param name="IsArrayRoot"><c>true</c> when this leaf represents an array whose element
|
||||
/// count exceeded the configured expansion limit; the caller should expose it as a
|
||||
/// whole-array tag rather than per-element.</param>
|
||||
/// <param name="ArrayLength">Element count when <see cref="IsArrayRoot"/> is <c>true</c>.</param>
|
||||
public sealed record DiscoveredLeaf(
|
||||
string InstancePath,
|
||||
TwinCATDataType? AtomicType,
|
||||
int Offset,
|
||||
bool ReadOnly,
|
||||
bool IsArrayRoot = false,
|
||||
int? ArrayLength = null);
|
||||
|
||||
/// <summary>
|
||||
/// Walk <paramref name="root"/> and yield every atomic leaf reachable from it. Path
|
||||
/// prefixes start at <paramref name="instancePathRoot"/>; offsets accumulate from
|
||||
/// <paramref name="offsetRoot"/>.
|
||||
/// </summary>
|
||||
public static IEnumerable<DiscoveredLeaf> Walk(
|
||||
IDataType? root,
|
||||
string instancePathRoot,
|
||||
int offsetRoot,
|
||||
bool readOnly,
|
||||
TwinCATDriverOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (root is null) yield break;
|
||||
|
||||
var visited = new HashSet<IDataType>(ReferenceEqualityComparer.Instance);
|
||||
foreach (var leaf in WalkInner(root, instancePathRoot, offsetRoot, readOnly, options, visited, depth: 0))
|
||||
yield return leaf;
|
||||
}
|
||||
|
||||
private static IEnumerable<DiscoveredLeaf> WalkInner(
|
||||
IDataType type,
|
||||
string instancePath,
|
||||
int offset,
|
||||
bool readOnly,
|
||||
TwinCATDriverOptions options,
|
||||
HashSet<IDataType> visited,
|
||||
int depth)
|
||||
{
|
||||
if (depth >= MaxDepth) yield break; // depth cap — self-pointer / cyclic alias defence
|
||||
if (!visited.Add(type)) yield break; // already on the recursion stack — break the cycle
|
||||
|
||||
try
|
||||
{
|
||||
// Pointers / references — out of scope. Surfacing them would require dereferencing
|
||||
// through the AMS routing layer which has its own access patterns. The Beckhoff
|
||||
// 7.x stack moved the IsPointer / IsReference checks off IDataType onto the
|
||||
// DataTypeExtension static helpers; using DataTypeCategory directly avoids the
|
||||
// obsolete-warning and is the same wire-level signal.
|
||||
if (type.Category is DataTypeCategory.Pointer or DataTypeCategory.Reference) yield break;
|
||||
|
||||
// Aliases (incl. enums, since IEnumType : IAliasType) — recurse into BaseType
|
||||
// without changing the path / offset. Atomic terminus is handled when the base
|
||||
// hits a primitive.
|
||||
if (type.Category is DataTypeCategory.Alias or DataTypeCategory.Enum
|
||||
&& type is IAliasType alias && alias.BaseType is not null)
|
||||
{
|
||||
foreach (var leaf in WalkInner(alias.BaseType, instancePath, offset, readOnly, options, visited, depth + 1))
|
||||
yield return leaf;
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Atomic primitives + strings — terminate. AdsTwinCATClient.MapSymbolTypeName is the
|
||||
// single source of truth for IEC name → enum mapping; reuse it so alias chains that
|
||||
// bottom out at primitives match the same surface as flat-mode browse.
|
||||
if (type.Category is DataTypeCategory.Primitive or DataTypeCategory.String)
|
||||
{
|
||||
var atomic = AdsTwinCATClient.ResolveSymbolDataType(type);
|
||||
if (atomic is TwinCATDataType dt)
|
||||
yield return new DiscoveredLeaf(instancePath, dt, offset, readOnly);
|
||||
// Unknown primitive name — drop. Caller still gets the rest of the tree.
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Structs — recurse into each member, appending ".Name" to the path and adding the
|
||||
// member's byte offset. IStructType.AllMembers walks the inheritance chain so derived
|
||||
// FB / struct types still surface members from the base — Members alone would miss them.
|
||||
if (type is IStructType structType)
|
||||
{
|
||||
var members = structType.AllMembers ?? structType.Members;
|
||||
if (members is null) yield break;
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (member?.DataType is null) continue;
|
||||
var childPath = string.IsNullOrEmpty(instancePath)
|
||||
? member.InstanceName
|
||||
: instancePath + "." + member.InstanceName;
|
||||
var childOffset = offset + member.ByteOffset;
|
||||
foreach (var leaf in WalkInner(member.DataType, childPath, childOffset, readOnly, options, visited, depth + 1))
|
||||
yield return leaf;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Arrays — element-by-element expansion up to MaxArrayExpansion. Arrays of atoms
|
||||
// produce N indexed leaves; arrays of structs produce N × (struct-member-count)
|
||||
// leaves, also bounded by the cap. Multi-dim arrays surface as ElementCount in
|
||||
// row-major order; a follow-up PR can break that out into per-dim indices if
|
||||
// operators ever need them.
|
||||
if (type is IArrayType arrayType)
|
||||
{
|
||||
var elementType = arrayType.ElementType;
|
||||
var dims = arrayType.Dimensions;
|
||||
if (elementType is null || dims is null)
|
||||
yield break;
|
||||
|
||||
var elementCount = dims.ElementCount;
|
||||
if (elementCount <= 0)
|
||||
yield break;
|
||||
|
||||
if (elementCount > options.MaxArrayExpansion)
|
||||
{
|
||||
// Over the cap — surface only the array root. Element type may be atomic or
|
||||
// structured; for atomic element types we set AtomicType so the caller can
|
||||
// configure a whole-array read against it. For struct element types we
|
||||
// leave AtomicType null + flag IsArrayRoot — the address-space layer
|
||||
// typically renders this as a folder placeholder.
|
||||
var rootAtomic = AdsTwinCATClient.ResolveSymbolDataType(elementType);
|
||||
yield return new DiscoveredLeaf(
|
||||
instancePath, rootAtomic, offset, readOnly,
|
||||
IsArrayRoot: true, ArrayLength: elementCount);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var elementSize = Math.Max(0, elementType.ByteSize);
|
||||
var lowerBounds = dims.LowerBounds;
|
||||
var dimLengths = dims.GetDimensionLengths();
|
||||
// Multi-dim — emit per-element with row-major index linearisation. Single-dim
|
||||
// is just the trivial case of that loop.
|
||||
var indices = new int[dimLengths.Length];
|
||||
for (var flat = 0; flat < elementCount; flat++)
|
||||
{
|
||||
LinearToIndices(flat, dimLengths, indices);
|
||||
var indexedPath = AppendIndices(instancePath, indices, lowerBounds);
|
||||
var elementOffset = offset + flat * elementSize;
|
||||
foreach (var leaf in WalkInner(elementType, indexedPath, elementOffset, readOnly, options, visited, depth + 1))
|
||||
yield return leaf;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Anything else (Union / FB instance / Interface / Unknown) — out of scope.
|
||||
yield break;
|
||||
}
|
||||
finally
|
||||
{
|
||||
visited.Remove(type);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a flat row-major index into per-dimension indices (in-place into
|
||||
/// <paramref name="indices"/>). Single-dim arrays just write <c>flat</c> to slot 0.
|
||||
/// </summary>
|
||||
private static void LinearToIndices(int flat, int[] dimLengths, int[] indices)
|
||||
{
|
||||
for (var d = dimLengths.Length - 1; d >= 0; d--)
|
||||
{
|
||||
var len = dimLengths[d] <= 0 ? 1 : dimLengths[d];
|
||||
indices[d] = flat % len;
|
||||
flat /= len;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render <c>Path[i]</c> / <c>Path[i,j]</c> form for an array element. Lower bounds add
|
||||
/// so an <c>ARRAY[1..N]</c> element 0 surfaces as <c>Path[1]</c> rather than <c>Path[0]</c>.
|
||||
/// </summary>
|
||||
private static string AppendIndices(string path, int[] indices, int[]? lowerBounds)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(path.Length + 4 + indices.Length * 4);
|
||||
sb.Append(path);
|
||||
sb.Append('[');
|
||||
for (var i = 0; i < indices.Length; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
var lb = lowerBounds is { Length: > 0 } && i < lowerBounds.Length ? lowerBounds[i] : 0;
|
||||
sb.Append(indices[i] + lb);
|
||||
}
|
||||
sb.Append(']');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR 4.1 / #315 — integration coverage for nested-UDT browse. Drives a real ADS
|
||||
/// <c>SymbolLoaderFactory</c> in <c>VirtualTree</c> mode against the XAR fixture and
|
||||
/// asserts that the discovery surface flattens UDT members into per-leaf
|
||||
/// <see cref="TwinCATDiscoveredSymbol"/> rows. Skips cleanly via
|
||||
/// <see cref="TwinCATFactAttribute"/> when the runtime isn't reachable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Required PLC project state</b> (see <c>TwinCatProject/README.md</c> §UDT):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ST_NestedFlags</c> DUT with at least 3 atomic members.</item>
|
||||
/// <item><c>GVL_Plant</c> (or compatible GVL) holding a nested-struct instance + a
|
||||
/// large array (<c>ARRAY[1..2000] OF ST_AlarmRecord</c>) for cutoff coverage.</item>
|
||||
/// </list>
|
||||
/// <para>The fixture project today is a stub (the <c>.tsproj</c> ships once the XAR VM
|
||||
/// is up). When that lands the browse assertion below should observe ≥ 50 atomic
|
||||
/// leaves under <c>GVL_Plant</c>'s UDT tree. Until then the test is build-time
|
||||
/// coverage.</para>
|
||||
/// </remarks>
|
||||
[Collection("TwinCATXar")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "TwinCAT-XAR")]
|
||||
public sealed class TwinCATUdtBrowseTests(TwinCATXarFixture sim)
|
||||
{
|
||||
[TwinCATFact]
|
||||
public async Task Driver_browses_UDT_tree_and_flattens_to_atomic_leaves()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
var hostAddress = $"ads://{sim.TargetNetId}:{sim.AmsPort}";
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(hostAddress, "TwinCAT-Smoke")],
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = true,
|
||||
// Default 1024 already sits below the 2000-element ARRAY[1..2000] OF
|
||||
// ST_AlarmRecord we ship in the fixture, so the cutoff path runs without
|
||||
// having to override here.
|
||||
};
|
||||
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-udt-browse");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
|
||||
// Sanity: discovery completed + the Discovered/ folder materialised.
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Discovered");
|
||||
// At least one atomic leaf surfaced. Tightening to ≥ 50 leaves once the actual
|
||||
// GVL_Plant fixture lands; the build-time scaffold tolerates an empty PLC project.
|
||||
builder.Variables.Count.ShouldBeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_AlarmRecord" Id="{00000000-0000-0000-0000-000000000403}">
|
||||
<Declaration><![CDATA[// PR 4.1 / #315 — element type for the GVL_Plant.aAlarmRecords ARRAY[1..2000] cutoff
|
||||
// fixture. Two atomic members per element so an over-cap browse short-circuits to a
|
||||
// single IsArrayRoot leaf rather than 2000 × 2 = 4000 individual leaves.
|
||||
TYPE ST_AlarmRecord :
|
||||
STRUCT
|
||||
nCode : DINT;
|
||||
bActive : BOOL;
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_NestedFlags" Id="{00000000-0000-0000-0000-000000000401}">
|
||||
<Declaration><![CDATA[// PR 4.1 / #315 — exercises TwinCATTypeWalker.Walk against a mixed-atomic struct.
|
||||
// Members chosen to span integer / boolean / real so the per-leaf flatten emits one
|
||||
// row per type the OPC UA layer renders. Bit-packed BOOL members reuse the existing
|
||||
// PR 1.5 bit-extract path on read.
|
||||
TYPE ST_NestedFlags :
|
||||
STRUCT
|
||||
bRunning : BOOL;
|
||||
bFault : BOOL;
|
||||
bWarning : BOOL;
|
||||
nState : INT;
|
||||
rTemperature : REAL;
|
||||
sTagName : STRING(40);
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_RecursiveCap" Id="{00000000-0000-0000-0000-000000000402}">
|
||||
<Declaration><![CDATA[// PR 4.1 / #315 — exercises the depth-cap / cycle-guard path in TwinCATTypeWalker.
|
||||
// A POINTER TO ST_RecursiveCap surfaces in the IDataType graph as IsPointer=true
|
||||
// (DataTypeCategory.Pointer); the walker should skip the pointer member rather than
|
||||
// recurse, leaving nValue as the only atomic leaf. If the cycle guard ever regresses
|
||||
// the walker would either stack-overflow or emit a flood of self-referential paths,
|
||||
// both of which the integration test asserts against.
|
||||
TYPE ST_RecursiveCap :
|
||||
STRUCT
|
||||
nValue : DINT;
|
||||
pNext : POINTER TO ST_RecursiveCap;
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<GVL Name="GVL_Plant" Id="{00000000-0000-0000-0000-000000000404}">
|
||||
<Declaration><![CDATA[// PR 4.1 / #315 — UDT decomposition + array-cutoff fixture for TwinCATUdtBrowseTests.
|
||||
// stFlags : nested flags struct exercises the per-member flatten path (one OPC UA
|
||||
// variable per atomic field). aAlarmRecords : 2000-element array of struct-typed
|
||||
// elements exercises the MaxArrayExpansion cutoff; default cap is 1024, so the
|
||||
// browse short-circuits to a single IsArrayRoot leaf instead of 4000 individual
|
||||
// rows. Keep the GVL small overall so the symbol table stays under the AMS request
|
||||
// budget.
|
||||
VAR_GLOBAL
|
||||
stFlags : ST_NestedFlags;
|
||||
aAlarmRecords : ARRAY[1..2000] OF ST_AlarmRecord;
|
||||
END_VAR
|
||||
]]></Declaration>
|
||||
</GVL>
|
||||
</TcPlcObject>
|
||||
@@ -198,6 +198,45 @@ Options to eliminate the manual step:
|
||||
the rotation permanently, worth it if the integration host is
|
||||
long-lived.
|
||||
|
||||
## Complex hierarchy
|
||||
|
||||
PR 4.1 / #315 (nested-UDT browse via online type walker) exercises
|
||||
`TwinCATTypeWalker.Walk` against a real PLC symbol graph. The fixture
|
||||
state required:
|
||||
|
||||
### DUTs
|
||||
|
||||
- `PLC/DUTs/ST_NestedFlags.TcDUT` — mixed-atomic struct (BOOL / INT / REAL /
|
||||
STRING) used for the per-member flatten coverage.
|
||||
- `PLC/DUTs/ST_AlarmRecord.TcDUT` — small two-field struct used as the
|
||||
element type of the cutoff array.
|
||||
- `PLC/DUTs/ST_RecursiveCap.TcDUT` — struct with a `POINTER TO`
|
||||
self-reference; verifies the walker's pointer-skip + cycle-guard
|
||||
paths terminate without exploding the symbol stream.
|
||||
|
||||
### GVL: `GVL_Plant`
|
||||
|
||||
```st
|
||||
VAR_GLOBAL
|
||||
stFlags : ST_NestedFlags;
|
||||
aAlarmRecords : ARRAY[1..2000] OF ST_AlarmRecord;
|
||||
END_VAR
|
||||
```
|
||||
|
||||
`stFlags` produces N atomic leaves where N = the number of `ST_NestedFlags`
|
||||
fields. `aAlarmRecords` has 2000 elements which exceeds the default
|
||||
`TwinCATDriverOptions.MaxArrayExpansion` (1024) — discovery surfaces it as
|
||||
a single `IsArrayRoot` leaf rather than 4000 per-element rows. Lower the
|
||||
cap on the driver instance to force per-element expansion (or raise it if
|
||||
the operator wants the per-element view).
|
||||
|
||||
The XAE-form artefacts ship at `PLC/DUTs/*.TcDUT` + `PLC/GVLs/GVL_Plant.TcGVL`;
|
||||
import them into the PLC project alongside `GVL_Fixture` + `GVL_Perf`.
|
||||
|
||||
The integration test that exercises this fixture lives at
|
||||
`tests/.../TwinCATUdtBrowseTests.cs` and skips via `[TwinCATFact]` when
|
||||
the XAR runtime isn't reachable.
|
||||
|
||||
## Online-change test scenario
|
||||
|
||||
PR 2.3 (proactive Symbol-Version invalidation listener) ships an
|
||||
|
||||
@@ -361,9 +361,18 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
public List<TwinCATDiscoveredSymbol> BrowseResults { get; } = new();
|
||||
public bool ThrowOnBrowse { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PR 4.1 / #315 — captures the most recent <c>maxArrayExpansion</c> the driver passed
|
||||
/// so tests can assert the option threaded through. Defaults to <c>-1</c> until the
|
||||
/// first browse call; <c>0</c> would be a meaningful (degenerate) caller value.
|
||||
/// </summary>
|
||||
public int LastBrowseMaxArrayExpansion { get; private set; } = -1;
|
||||
|
||||
public virtual async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
|
||||
int maxArrayExpansion,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
LastBrowseMaxArrayExpansion = maxArrayExpansion;
|
||||
if (ThrowOnBrowse) throw Exception ?? new InvalidOperationException("fake browse failure");
|
||||
await Task.CompletedTask;
|
||||
foreach (var sym in BrowseResults)
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
using System.Collections;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// PR 4.1 / #315 — coverage for <see cref="TwinCATTypeWalker"/>. Each test builds a
|
||||
/// synthetic <see cref="IDataType"/> tree using the in-test stubs below (avoiding
|
||||
/// dependence on the Beckhoff <c>SymbolType</c> / <c>StructType</c> internal ctors)
|
||||
/// and asserts the flattened-leaf shape the walker emits.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATTypeWalkerTests
|
||||
{
|
||||
private static readonly TwinCATDriverOptions DefaultOptions = new();
|
||||
|
||||
[Fact]
|
||||
public void Atomic_root_emits_single_leaf()
|
||||
{
|
||||
var dint = new PrimitiveType("DINT", typeof(int));
|
||||
|
||||
var leaves = TwinCATTypeWalker
|
||||
.Walk(dint, "MAIN.nCounter", offsetRoot: 0, readOnly: false, DefaultOptions)
|
||||
.ToList();
|
||||
|
||||
leaves.Count.ShouldBe(1);
|
||||
leaves[0].InstancePath.ShouldBe("MAIN.nCounter");
|
||||
leaves[0].AtomicType.ShouldBe(TwinCATDataType.DInt);
|
||||
leaves[0].IsArrayRoot.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_level_struct_emits_one_leaf_per_member()
|
||||
{
|
||||
var dint = new PrimitiveType("DINT", typeof(int));
|
||||
var real = new PrimitiveType("REAL", typeof(float));
|
||||
var boolType = new PrimitiveType("BOOL", typeof(bool));
|
||||
var st = new FakeStructType("ST_Sample",
|
||||
new FakeMember("nCounter", dint, byteOffset: 0),
|
||||
new FakeMember("rSetpoint", real, byteOffset: 4),
|
||||
new FakeMember("bRunning", boolType, byteOffset: 8));
|
||||
|
||||
var leaves = TwinCATTypeWalker
|
||||
.Walk(st, "MAIN.Sample", 0, false, DefaultOptions)
|
||||
.ToList();
|
||||
|
||||
leaves.Select(l => l.InstancePath).ShouldBe(
|
||||
["MAIN.Sample.nCounter", "MAIN.Sample.rSetpoint", "MAIN.Sample.bRunning"]);
|
||||
leaves.Select(l => l.AtomicType).ShouldBe(
|
||||
[TwinCATDataType.DInt, TwinCATDataType.Real, TwinCATDataType.Bool]);
|
||||
leaves.Select(l => l.Offset).ShouldBe([0, 4, 8]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nested_struct_recurses_with_dotted_paths()
|
||||
{
|
||||
var dint = new PrimitiveType("DINT", typeof(int));
|
||||
var inner = new FakeStructType("ST_Inner",
|
||||
new FakeMember("nValue", dint, byteOffset: 0),
|
||||
new FakeMember("nFlags", dint, byteOffset: 4));
|
||||
var outer = new FakeStructType("ST_Outer",
|
||||
new FakeMember("Inner", inner, byteOffset: 8),
|
||||
new FakeMember("nTop", dint, byteOffset: 0));
|
||||
|
||||
var leaves = TwinCATTypeWalker
|
||||
.Walk(outer, "GVL.Sample", 0, false, DefaultOptions)
|
||||
.ToList();
|
||||
|
||||
// First member walks into Inner — its offset is 8, so leaves are at 8/12; nTop at 0.
|
||||
leaves.Select(l => (l.InstancePath, l.Offset)).ShouldBe(
|
||||
[
|
||||
("GVL.Sample.Inner.nValue", 8),
|
||||
("GVL.Sample.Inner.nFlags", 12),
|
||||
("GVL.Sample.nTop", 0),
|
||||
]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_of_atomic_within_bound_emits_per_element_leaves()
|
||||
{
|
||||
var dint = new PrimitiveType("DINT", typeof(int));
|
||||
var arr = new FakeArrayType("ARRAY[1..5] OF DINT", dint,
|
||||
elementByteSize: 4, lowerBound: 1, length: 5);
|
||||
|
||||
var leaves = TwinCATTypeWalker
|
||||
.Walk(arr, "GVL.Tags", 0, false, DefaultOptions)
|
||||
.ToList();
|
||||
|
||||
leaves.Count.ShouldBe(5);
|
||||
leaves.Select(l => l.InstancePath).ShouldBe(
|
||||
["GVL.Tags[1]", "GVL.Tags[2]", "GVL.Tags[3]", "GVL.Tags[4]", "GVL.Tags[5]"]);
|
||||
leaves.All(l => l.AtomicType == TwinCATDataType.DInt).ShouldBeTrue();
|
||||
leaves.Select(l => l.Offset).ShouldBe([0, 4, 8, 12, 16]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_of_atomic_over_bound_emits_single_root_leaf()
|
||||
{
|
||||
var dint = new PrimitiveType("DINT", typeof(int));
|
||||
var arr = new FakeArrayType("ARRAY[1..5000] OF DINT", dint,
|
||||
elementByteSize: 4, lowerBound: 1, length: 5000);
|
||||
|
||||
var leaves = TwinCATTypeWalker
|
||||
.Walk(arr, "GVL.Big", 0, false, DefaultOptions)
|
||||
.ToList();
|
||||
|
||||
leaves.Count.ShouldBe(1);
|
||||
leaves[0].InstancePath.ShouldBe("GVL.Big");
|
||||
leaves[0].IsArrayRoot.ShouldBeTrue();
|
||||
leaves[0].ArrayLength.ShouldBe(5000);
|
||||
leaves[0].AtomicType.ShouldBe(TwinCATDataType.DInt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Array_of_struct_within_bound_expands_per_member_per_element()
|
||||
{
|
||||
var dint = new PrimitiveType("DINT", typeof(int));
|
||||
var real = new PrimitiveType("REAL", typeof(float));
|
||||
var st = new FakeStructType("ST_Pair",
|
||||
new FakeMember("nCount", dint, byteOffset: 0),
|
||||
new FakeMember("rValue", real, byteOffset: 4));
|
||||
var arr = new FakeArrayType("ARRAY[0..2] OF ST_Pair", st,
|
||||
elementByteSize: 8, lowerBound: 0, length: 3);
|
||||
|
||||
var leaves = TwinCATTypeWalker
|
||||
.Walk(arr, "GVL.Pairs", 0, false, DefaultOptions)
|
||||
.ToList();
|
||||
|
||||
// 3 elements × 2 members = 6 leaves, with progressing offsets.
|
||||
leaves.Count.ShouldBe(6);
|
||||
leaves.Select(l => l.InstancePath).ShouldBe(
|
||||
[
|
||||
"GVL.Pairs[0].nCount", "GVL.Pairs[0].rValue",
|
||||
"GVL.Pairs[1].nCount", "GVL.Pairs[1].rValue",
|
||||
"GVL.Pairs[2].nCount", "GVL.Pairs[2].rValue",
|
||||
]);
|
||||
leaves.Select(l => l.Offset).ShouldBe([0, 4, 8, 12, 16, 20]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alias_walks_through_to_base_type()
|
||||
{
|
||||
var dint = new PrimitiveType("DINT", typeof(int));
|
||||
var alias = new AliasType("DegreesC", dint);
|
||||
|
||||
var leaves = TwinCATTypeWalker
|
||||
.Walk(alias, "MAIN.tTemp", 0, false, DefaultOptions)
|
||||
.ToList();
|
||||
|
||||
leaves.Count.ShouldBe(1);
|
||||
leaves[0].InstancePath.ShouldBe("MAIN.tTemp");
|
||||
leaves[0].AtomicType.ShouldBe(TwinCATDataType.DInt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pointer_member_is_skipped()
|
||||
{
|
||||
var dint = new PrimitiveType("DINT", typeof(int));
|
||||
var ptr = new FakePointerType("POINTER TO DINT");
|
||||
var st = new FakeStructType("ST_WithPtr",
|
||||
new FakeMember("nValue", dint, byteOffset: 0),
|
||||
new FakeMember("pNext", ptr, byteOffset: 4));
|
||||
|
||||
var leaves = TwinCATTypeWalker
|
||||
.Walk(st, "MAIN.Node", 0, false, DefaultOptions)
|
||||
.ToList();
|
||||
|
||||
// Pointer member dropped; atomic neighbour preserved.
|
||||
leaves.Count.ShouldBe(1);
|
||||
leaves[0].InstancePath.ShouldBe("MAIN.Node.nValue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Self_referencing_struct_terminates_within_depth_cap()
|
||||
{
|
||||
var dint = new PrimitiveType("DINT", typeof(int));
|
||||
var st = new FakeStructType("ST_Recursive");
|
||||
// Self-pointer-via-reference: a member whose type is the same struct. Real PLCs would
|
||||
// use POINTER TO ST_Recursive (which is filtered by the IsPointer guard) but a direct
|
||||
// recursive struct is the stronger guard test.
|
||||
st.AddMember(new FakeMember("nValue", dint, byteOffset: 0));
|
||||
st.AddMember(new FakeMember("Self", st, byteOffset: 4));
|
||||
|
||||
var leaves = TwinCATTypeWalker
|
||||
.Walk(st, "MAIN.Recursive", 0, false, DefaultOptions)
|
||||
.ToList();
|
||||
|
||||
// Walker should terminate. nValue surfaces; the Self member breaks the cycle.
|
||||
leaves.Any().ShouldBeTrue();
|
||||
leaves.Any(l => l.InstancePath == "MAIN.Recursive.nValue").ShouldBeTrue();
|
||||
// No infinite recursion: count is bounded.
|
||||
leaves.Count.ShouldBeLessThan(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Custom_max_array_expansion_is_honored()
|
||||
{
|
||||
var dint = new PrimitiveType("DINT", typeof(int));
|
||||
var arr = new FakeArrayType("ARRAY[1..50] OF DINT", dint,
|
||||
elementByteSize: 4, lowerBound: 1, length: 50);
|
||||
var opts = new TwinCATDriverOptions { MaxArrayExpansion = 32 };
|
||||
|
||||
var leaves = TwinCATTypeWalker
|
||||
.Walk(arr, "GVL.Mid", 0, false, opts)
|
||||
.ToList();
|
||||
|
||||
// 50 > 32 → single-root leaf.
|
||||
leaves.Count.ShouldBe(1);
|
||||
leaves[0].IsArrayRoot.ShouldBeTrue();
|
||||
leaves[0].ArrayLength.ShouldBe(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_only_flag_propagates_to_every_leaf()
|
||||
{
|
||||
var dint = new PrimitiveType("DINT", typeof(int));
|
||||
var st = new FakeStructType("ST_RO",
|
||||
new FakeMember("a", dint, byteOffset: 0),
|
||||
new FakeMember("b", dint, byteOffset: 4));
|
||||
|
||||
var leaves = TwinCATTypeWalker
|
||||
.Walk(st, "GVL.Status", 0, readOnly: true, DefaultOptions)
|
||||
.ToList();
|
||||
|
||||
leaves.Count.ShouldBe(2);
|
||||
leaves.All(l => l.ReadOnly).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_path_root_does_not_emit_leading_dot()
|
||||
{
|
||||
var dint = new PrimitiveType("DINT", typeof(int));
|
||||
var st = new FakeStructType("ST_Empty",
|
||||
new FakeMember("Field", dint, byteOffset: 0));
|
||||
|
||||
var leaves = TwinCATTypeWalker
|
||||
.Walk(st, "", 0, false, DefaultOptions)
|
||||
.ToList();
|
||||
|
||||
leaves.Single().InstancePath.ShouldBe("Field");
|
||||
}
|
||||
|
||||
// ---- in-test stubs implementing the TwinCAT interface surface the walker depends on ----
|
||||
|
||||
/// <summary>
|
||||
/// Minimal struct type stub. Implements <see cref="IStructType"/> but only the surface
|
||||
/// <see cref="TwinCATTypeWalker"/> consumes — every other member throws so accidental
|
||||
/// dependence in future tests surfaces loudly.
|
||||
/// </summary>
|
||||
private sealed class FakeStructType : IStructType, IInterfaceType
|
||||
{
|
||||
private readonly List<IMember> _members = new();
|
||||
public FakeStructType(string name, params IMember[] members)
|
||||
{
|
||||
Name = name;
|
||||
foreach (var m in members) _members.Add(m);
|
||||
}
|
||||
public void AddMember(IMember m) => _members.Add(m);
|
||||
|
||||
public string Name { get; }
|
||||
public DataTypeCategory Category => DataTypeCategory.Struct;
|
||||
public IMemberCollection Members => new FakeMemberCollection(_members);
|
||||
public IMemberCollection AllMembers => new FakeMemberCollection(_members);
|
||||
public string FullName => Name;
|
||||
public string Namespace => string.Empty;
|
||||
public int Id => 0;
|
||||
public string Comment => string.Empty;
|
||||
public ITypeAttributeCollection Attributes => null!;
|
||||
public bool IsContainer => true;
|
||||
public bool IsPointer => false;
|
||||
public bool IsReference => false;
|
||||
public bool IsPrimitive => false;
|
||||
public int Size => 0;
|
||||
public int ByteSize => _members.Sum(m => m.ByteSize);
|
||||
public int BitSize => ByteSize * 8;
|
||||
public bool IsBitType => false;
|
||||
public bool IsByteAligned => true;
|
||||
|
||||
public bool HasStaticFields => false;
|
||||
public bool HasRpcMethods => false;
|
||||
public string[] InterfaceImplementationNames => Array.Empty<string>();
|
||||
public IInterfaceType[] InterfaceImplementations => Array.Empty<IInterfaceType>();
|
||||
public string BaseTypeName => string.Empty;
|
||||
public IDataType? BaseType => null;
|
||||
public IRpcMethodCollection RpcMethods => null!;
|
||||
}
|
||||
|
||||
/// <summary>Member stub — only Name / DataType / ByteOffset are consumed by the walker.</summary>
|
||||
private sealed class FakeMember(string name, IDataType type, int byteOffset) : IMember
|
||||
{
|
||||
public IDataType DataType { get; } = type;
|
||||
public string TypeName => DataType.Name;
|
||||
public string InstanceName { get; } = name;
|
||||
public string InstancePath => InstanceName;
|
||||
public bool IsStatic => false;
|
||||
public bool IsReference => false;
|
||||
public bool IsPointer => false;
|
||||
public string Comment => string.Empty;
|
||||
public bool IsProperty => false;
|
||||
public IDataType ParentType => null!;
|
||||
public int Offset => byteOffset;
|
||||
public int ByteOffset { get; } = byteOffset;
|
||||
public int BitOffset => byteOffset * 8;
|
||||
public int Size => DataType.ByteSize;
|
||||
public int ByteSize => DataType.ByteSize;
|
||||
public int BitSize => DataType.BitSize;
|
||||
public bool IsBitType => false;
|
||||
public bool IsByteAligned => true;
|
||||
public ITypeAttributeCollection Attributes => null!;
|
||||
public System.Text.Encoding ValueEncoding => System.Text.Encoding.ASCII;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps an <see cref="IList{T}"/> of members in just enough <see cref="IMemberCollection"/>
|
||||
/// surface for the walker. We don't implement add / remove / etc. because the walker
|
||||
/// only enumerates; calls to mutating members deliberately throw.
|
||||
/// </summary>
|
||||
private sealed class FakeMemberCollection(List<IMember> items) : IMemberCollection
|
||||
{
|
||||
public IMember this[int index] { get => items[index]; set => throw new NotSupportedException(); }
|
||||
public IMember this[string name] => items.First(m => m.InstanceName == name);
|
||||
public int Count => items.Count;
|
||||
public bool IsReadOnly => true;
|
||||
public IInstanceCollection<IMember> Statics => null!;
|
||||
public IInstanceCollection<IMember> Instances => null!;
|
||||
public InstanceCollectionMode Mode => InstanceCollectionMode.Names;
|
||||
|
||||
public void Add(IMember item) => throw new NotSupportedException();
|
||||
public void Clear() => throw new NotSupportedException();
|
||||
public bool Contains(IMember item) => items.Contains(item);
|
||||
public void CopyTo(IMember[] array, int arrayIndex) => items.CopyTo(array, arrayIndex);
|
||||
public bool Remove(IMember item) => throw new NotSupportedException();
|
||||
public IEnumerator<IMember> GetEnumerator() => items.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => items.GetEnumerator();
|
||||
public int IndexOf(IMember item) => items.IndexOf(item);
|
||||
public void Insert(int index, IMember item) => throw new NotSupportedException();
|
||||
public void RemoveAt(int index) => throw new NotSupportedException();
|
||||
|
||||
public bool Contains(string instancePath) => items.Any(m => m.InstanceName == instancePath);
|
||||
public bool ContainsName(string name) => items.Any(m => m.InstanceName == name);
|
||||
public bool TryGetInstance(string instancePath, out IMember symbol)
|
||||
{
|
||||
symbol = items.FirstOrDefault(m => m.InstanceName == instancePath)!;
|
||||
return symbol is not null;
|
||||
}
|
||||
public bool TryGetInstanceByName(string name, out IList<IMember>? matches)
|
||||
{
|
||||
matches = items.Where(m => m.InstanceName == name).ToList();
|
||||
return matches.Count > 0;
|
||||
}
|
||||
public IMember GetInstance(string instancePath) =>
|
||||
items.First(m => m.InstanceName == instancePath);
|
||||
public IList<IMember> GetInstanceByName(string name) =>
|
||||
items.Where(m => m.InstanceName == name).ToList();
|
||||
public bool TryGetMember(string name, out IMember? member)
|
||||
{
|
||||
member = items.FirstOrDefault(m => m.InstanceName == name);
|
||||
return member is not null;
|
||||
}
|
||||
public int CalcSize() => items.Sum(m => m.ByteSize);
|
||||
}
|
||||
|
||||
/// <summary>Array type stub — fixed dimension list with configurable lower bound + length.</summary>
|
||||
private sealed class FakeArrayType(string name, IDataType elementType,
|
||||
int elementByteSize, int lowerBound, int length) : IArrayType
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public DataTypeCategory Category => DataTypeCategory.Array;
|
||||
public IDataType ElementType { get; } = elementType;
|
||||
public string ElementTypeName => ElementType.Name;
|
||||
public IDimensionCollection Dimensions { get; } = new FakeDimensionCollection(lowerBound, length);
|
||||
public bool IsJagged => false;
|
||||
public int JaggedLevel => 1;
|
||||
public string FullName => Name;
|
||||
public string Namespace => string.Empty;
|
||||
public int Id => 0;
|
||||
public string Comment => string.Empty;
|
||||
public ITypeAttributeCollection Attributes => null!;
|
||||
public bool IsContainer => true;
|
||||
public bool IsPointer => false;
|
||||
public bool IsReference => false;
|
||||
public bool IsPrimitive => false;
|
||||
public int Size => length * elementByteSize;
|
||||
public int ByteSize => length * elementByteSize;
|
||||
public int BitSize => ByteSize * 8;
|
||||
public bool IsBitType => false;
|
||||
public bool IsByteAligned => true;
|
||||
}
|
||||
|
||||
private sealed class FakeDimensionCollection(int lowerBound, int length) : IDimensionCollection
|
||||
{
|
||||
private readonly List<IDimension> _dims = new() { new FakeDimension(lowerBound, length) };
|
||||
public int ElementCount => length;
|
||||
public int[] LowerBounds => new[] { lowerBound };
|
||||
public int[] UpperBounds => new[] { lowerBound + length - 1 };
|
||||
public bool IsNonZeroBased => lowerBound != 0;
|
||||
public int[] GetDimensionLengths() => new[] { length };
|
||||
|
||||
public IDimension this[int index] { get => _dims[index]; set => throw new NotSupportedException(); }
|
||||
public int Count => _dims.Count;
|
||||
public bool IsReadOnly => true;
|
||||
public void Add(IDimension item) => throw new NotSupportedException();
|
||||
public void Clear() => throw new NotSupportedException();
|
||||
public bool Contains(IDimension item) => _dims.Contains(item);
|
||||
public void CopyTo(IDimension[] array, int arrayIndex) => _dims.CopyTo(array, arrayIndex);
|
||||
public bool Remove(IDimension item) => throw new NotSupportedException();
|
||||
public IEnumerator<IDimension> GetEnumerator() => _dims.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => _dims.GetEnumerator();
|
||||
public int IndexOf(IDimension item) => _dims.IndexOf(item);
|
||||
public void Insert(int index, IDimension item) => throw new NotSupportedException();
|
||||
public void RemoveAt(int index) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed record FakeDimension(int LowerBound, int ElementCount) : IDimension;
|
||||
|
||||
/// <summary>Pointer type stub — exists solely so the walker's pointer-skip path runs.</summary>
|
||||
private sealed class FakePointerType(string name) : IDataType
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public DataTypeCategory Category => DataTypeCategory.Pointer;
|
||||
public string FullName => Name;
|
||||
public string Namespace => string.Empty;
|
||||
public int Id => 0;
|
||||
public string Comment => string.Empty;
|
||||
public ITypeAttributeCollection Attributes => null!;
|
||||
public bool IsContainer => false;
|
||||
public bool IsPointer => true;
|
||||
public bool IsReference => false;
|
||||
public bool IsPrimitive => false;
|
||||
public int Size => 4;
|
||||
public int ByteSize => 4;
|
||||
public int BitSize => 32;
|
||||
public bool IsBitType => false;
|
||||
public bool IsByteAligned => true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user