diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs index bbf86c6..5c684c4 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -1,5 +1,9 @@ using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using TwinCAT; using TwinCAT.Ads; +using TwinCAT.Ads.TypeSystem; +using TwinCAT.TypeSystem; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; @@ -149,6 +153,56 @@ internal sealed class AdsTwinCATClient : ITwinCATClient catch { /* best-effort tear-down; target may already be gone */ } } + public async IAsyncEnumerable BrowseSymbolsAsync( + [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); + 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) + { + if (cancellationToken.IsCancellationRequested) yield break; + var mapped = MapSymbolTypeName(symbol.DataType?.Name); + var readOnly = !IsSymbolWritable(symbol); + yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly); + } + } + + private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName 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, + "LINT" or "LWORD" => TwinCATDataType.LInt, + "ULINT" => TwinCATDataType.ULInt, + "REAL" => TwinCATDataType.Real, + "LREAL" => TwinCATDataType.LReal, + "STRING" => TwinCATDataType.String, + "WSTRING" => TwinCATDataType.WString, + "TIME" => TwinCATDataType.Time, + "DATE" => TwinCATDataType.Date, + "DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime, + "TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay, + _ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope + }; + + private static bool IsSymbolWritable(ISymbol symbol) + { + // SymbolAccessRights is a flags enum — the Write bit indicates a writable symbol. + // When the symbol implementation doesn't surface it, assume writable + let the PLC + // return AccessDenied at write time. + if (symbol is Symbol s) return (s.AccessRights & SymbolAccessRights.Write) != 0; + return true; + } + public void Dispose() { _client.AdsNotificationEx -= OnAdsNotificationEx; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs index 66e19c0..9ee748a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs @@ -66,11 +66,33 @@ public interface ITwinCATClient : IDisposable TimeSpan cycleTime, Action onChange, CancellationToken cancellationToken); + + /// + /// Walk the target's symbol table via the TwinCAT SymbolLoaderFactory (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 DataType = null so callers can + /// decide whether to drill in via their own walker. + /// + IAsyncEnumerable BrowseSymbolsAsync(CancellationToken cancellationToken); } /// Opaque handle for a registered ADS notification. tears it down. public interface ITwinCATNotificationHandle : IDisposable { } +/// +/// One symbol yielded by — full instance +/// path + detected + read-only flag. +/// +/// Full dotted symbol path (e.g. MAIN.bStart, GVL.Counter). +/// Mapped ; null when the symbol's type +/// doesn't map onto our supported atomic surface (UDTs, pointers, function blocks). +/// true when the symbol's AccessRights flag forbids writes. +public sealed record TwinCATDiscoveredSymbol( + string InstancePath, + TwinCATDataType? DataType, + bool ReadOnly); + /// Factory for s. One client per device. public interface ITwinCATClientFactory { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 145b575..c265b35 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -217,7 +217,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery // ---- ITagDiscovery ---- - public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(builder); var root = builder.Folder("TwinCAT", "TwinCAT"); @@ -225,6 +225,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery { var label = device.DeviceName ?? device.HostAddress; var deviceFolder = root.Folder(device.HostAddress, label); + + // Pre-declared tags — always emitted as the authoritative config path. var tagsForDevice = _options.Tags.Where(t => string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); foreach (var tag in tagsForDevice) @@ -241,8 +243,42 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery IsAlarm: false, WriteIdempotent: tag.WriteIdempotent)); } + + // 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. + if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state)) + { + IAddressSpaceBuilder? discoveredFolder = null; + try + { + var client = await EnsureConnectedAsync(state, cancellationToken).ConfigureAwait(false); + await foreach (var sym in client.BrowseSymbolsAsync(cancellationToken).ConfigureAwait(false)) + { + if (TwinCATSystemSymbolFilter.IsSystemSymbol(sym.InstancePath)) continue; + if (sym.DataType is not TwinCATDataType dt) continue; // unsupported type + + discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered"); + discoveredFolder.Variable(sym.InstancePath, sym.InstancePath, new DriverAttributeInfo( + FullName: sym.InstancePath, + DriverDataType: dt.ToDriverDataType(), + IsArray: false, + ArrayDim: null, + SecurityClass: sym.ReadOnly + ? SecurityClassification.ViewOnly + : SecurityClassification.Operate, + IsHistorized: false, + IsAlarm: false, + WriteIdempotent: false)); + } + } + catch (OperationCanceledException) { throw; } + catch + { + // Symbol-loader failure is non-fatal to discovery — pre-declared tags already + // shipped + operators see the failure in driver health on next read. + } + } } - return Task.CompletedTask; } // ---- ISubscribable (native ADS notifications with poll fallback) ---- diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs index be0671e..173a3a0 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs @@ -23,6 +23,15 @@ public sealed class TwinCATDriverOptions /// notification limits you can't raise. /// public bool UseNativeNotifications { get; init; } = true; + + /// + /// When true, DiscoverAsync walks each device's symbol table via the + /// TwinCAT SymbolLoaderFactory (flat mode) + surfaces controller-resident + /// globals / program locals under a Discovered/ sub-folder. Pre-declared tags + /// from always emit regardless. Default false to preserve + /// the strict-config path for deployments where only declared tags should appear. + /// + public bool EnableControllerBrowse { get; init; } } /// diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSystemSymbolFilter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSystemSymbolFilter.cs new file mode 100644 index 0000000..7a95595 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSystemSymbolFilter.cs @@ -0,0 +1,32 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +/// +/// Filter system / infrastructure symbols out of a TwinCAT symbol-loader walk. TC PLC +/// runtimes export plumbing symbols alongside user-declared ones — TwinCAT_SystemInfoVarList, +/// constants, IO task images, motion-layer internals — that clutter an OPC UA address space +/// if exposed. +/// +public static class TwinCATSystemSymbolFilter +{ + /// true when the symbol path matches a known system / infrastructure prefix. + public static bool IsSystemSymbol(string instancePath) + { + if (string.IsNullOrWhiteSpace(instancePath)) return true; + + // Runtime-exported info lists. + if (instancePath.StartsWith("TwinCAT_SystemInfoVarList", StringComparison.OrdinalIgnoreCase)) return true; + if (instancePath.StartsWith("TwinCAT_", StringComparison.OrdinalIgnoreCase)) return true; + if (instancePath.StartsWith("Global_Version", StringComparison.OrdinalIgnoreCase)) return true; + + // Constants pool — read-only, no operator value. + if (instancePath.StartsWith("Constants.", StringComparison.OrdinalIgnoreCase)) return true; + + // Anonymous / compiler-generated. + if (instancePath.StartsWith("__", StringComparison.Ordinal)) return true; + + // Motion / NC internals routinely surfaced by the symbol loader. + if (instancePath.StartsWith("Mc_", StringComparison.OrdinalIgnoreCase)) return true; + + return false; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs index f3bec53..9ff7b01 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; @@ -82,6 +83,23 @@ internal class FakeTwinCATClient : ITwinCATClient n.OnChange(symbolPath, value); } + // ---- symbol browser fake ---- + + public List BrowseResults { get; } = new(); + public bool ThrowOnBrowse { get; set; } + + public virtual async IAsyncEnumerable BrowseSymbolsAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (ThrowOnBrowse) throw Exception ?? new InvalidOperationException("fake browse failure"); + await Task.CompletedTask; + foreach (var sym in BrowseResults) + { + if (cancellationToken.IsCancellationRequested) yield break; + yield return sym; + } + } + public sealed class FakeNotification( string symbolPath, TwinCATDataType type, int? bitIndex, Action onChange, FakeTwinCATClient owner) : ITwinCATNotificationHandle diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolBrowserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolBrowserTests.cs new file mode 100644 index 0000000..95ea84b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolBrowserTests.cs @@ -0,0 +1,212 @@ +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.Tests; + +[Trait("Category", "Unit")] +public sealed class TwinCATSymbolBrowserTests +{ + [Fact] + public async Task Discovery_without_EnableControllerBrowse_emits_only_predeclared() + { + var builder = new RecordingBuilder(); + var factory = new FakeTwinCATClientFactory + { + Customise = () => + { + var c = new FakeTwinCATClient(); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Hidden", TwinCATDataType.DInt, false)); + return c; + }, + }; + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], + Tags = [new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)], + Probe = new TwinCATProbeOptions { Enabled = false }, + EnableControllerBrowse = false, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.Select(v => v.BrowseName).ShouldBe(["Declared"]); + builder.Folders.ShouldNotContain(f => f.BrowseName == "Discovered"); + } + + [Fact] + public async Task Discovery_with_browse_enabled_adds_controller_symbols_under_Discovered_folder() + { + var builder = new RecordingBuilder(); + var factory = new FakeTwinCATClientFactory + { + Customise = () => + { + var c = new FakeTwinCATClient(); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Counter", TwinCATDataType.DInt, ReadOnly: false)); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol("GVL.Setpoint", TwinCATDataType.Real, ReadOnly: false)); + return c; + }, + }; + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], + Probe = new TwinCATProbeOptions { Enabled = false }, + EnableControllerBrowse = true, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldContain(f => f.BrowseName == "Discovered"); + builder.Variables.Select(v => v.Info.FullName).ShouldContain("MAIN.Counter"); + builder.Variables.Select(v => v.Info.FullName).ShouldContain("GVL.Setpoint"); + } + + [Fact] + public async Task Browse_filters_system_symbols() + { + var builder = new RecordingBuilder(); + var factory = new FakeTwinCATClientFactory + { + Customise = () => + { + var c = new FakeTwinCATClient(); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol("TwinCAT_SystemInfoVarList._AppInfo", TwinCATDataType.DInt, false)); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol("Constants.PI", TwinCATDataType.LReal, true)); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol("Mc_InternalState", TwinCATDataType.DInt, true)); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol("__CompilerGen", TwinCATDataType.DInt, true)); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Real", TwinCATDataType.DInt, false)); + return c; + }, + }; + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], + Probe = new TwinCATProbeOptions { Enabled = false }, + EnableControllerBrowse = true, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.Select(v => v.Info.FullName).ShouldBe(["MAIN.Real"]); + } + + [Fact] + public async Task Browse_skips_symbols_with_null_datatype() + { + var builder = new RecordingBuilder(); + var factory = new FakeTwinCATClientFactory + { + Customise = () => + { + var c = new FakeTwinCATClient(); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Struct", DataType: null, ReadOnly: false)); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Counter", TwinCATDataType.DInt, false)); + return c; + }, + }; + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], + Probe = new TwinCATProbeOptions { Enabled = false }, + EnableControllerBrowse = true, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.Select(v => v.Info.FullName).ShouldBe(["MAIN.Counter"]); + } + + [Fact] + public async Task ReadOnly_symbol_surfaces_ViewOnly() + { + var builder = new RecordingBuilder(); + var factory = new FakeTwinCATClientFactory + { + Customise = () => + { + var c = new FakeTwinCATClient(); + c.BrowseResults.Add(new TwinCATDiscoveredSymbol("MAIN.Status", TwinCATDataType.DInt, ReadOnly: true)); + return c; + }, + }; + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], + Probe = new TwinCATProbeOptions { Enabled = false }, + EnableControllerBrowse = true, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); + } + + [Fact] + public async Task Browse_failure_is_non_fatal_predeclared_still_emits() + { + var builder = new RecordingBuilder(); + var factory = new FakeTwinCATClientFactory + { + Customise = () => new FakeTwinCATClient { ThrowOnBrowse = true }, + }; + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], + Tags = [new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)], + Probe = new TwinCATProbeOptions { Enabled = false }, + EnableControllerBrowse = true, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.Select(v => v.BrowseName).ShouldContain("Declared"); + } + + [Theory] + [InlineData("TwinCAT_SystemInfoVarList._AppInfo", true)] + [InlineData("TwinCAT_RuntimeInfo.Something", true)] + [InlineData("Constants.PI", true)] + [InlineData("Mc_AxisState", true)] + [InlineData("__hidden", true)] + [InlineData("Global_Version", true)] + [InlineData("MAIN.UserVar", false)] + [InlineData("GVL.Counter", false)] + [InlineData("MyFbInstance.State", false)] + [InlineData("", true)] + [InlineData(" ", true)] + public void SystemSymbolFilter_matches_expected_patterns(string path, bool expected) + { + TwinCATSystemSymbolFilter.IsSystemSymbol(path).ShouldBe(expected); + } + + // ---- helpers ---- + + 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 _, DriverDataType __, object? ___) { } + + 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) { } } + } +}