From c95228391d9b6c770dddd0926c4c4263e225cdf6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 20:13:33 -0400 Subject: [PATCH] =?UTF-8?q?TwinCAT=20follow-up=20=E2=80=94=20Symbol=20brow?= =?UTF-8?q?ser=20via=20AdsClient=20+=20SymbolLoaderFactory.=20Closes=20tas?= =?UTF-8?q?k=20#188.=20Adds=20ITwinCATClient.BrowseSymbolsAsync=20?= =?UTF-8?q?=E2=80=94=20IAsyncEnumerable=20yielding=20TwinCATDiscoveredSymb?= =?UTF-8?q?ol=20(InstancePath=20+=20mapped=20TwinCATDataType=20+=20ReadOnl?= =?UTF-8?q?y=20flag)=20from=20the=20target's=20flat=20symbol=20table.=20Ad?= =?UTF-8?q?sTwinCATClient=20implementation=20uses=20SymbolLoaderFactory.Cr?= =?UTF-8?q?eate(=5Fclient,=20new=20SymbolLoaderSettings(SymbolsLoadMode.Fl?= =?UTF-8?q?at))=20+=20iterates=20loader.Symbols,=20maps=20IEC=2061131-3=20?= =?UTF-8?q?type=20names=20(BOOL/SINT/INT/DINT/LINT/REAL/LREAL/STRING/WSTRI?= =?UTF-8?q?NG/TIME/DATE/DT/TOD=20+=20BYTE/WORD/DWORD/LWORD=20unsigned-word?= =?UTF-8?q?=20aliases)=20through=20MapSymbolTypeName,=20checks=20SymbolAcc?= =?UTF-8?q?essRights.Write=20bit=20for=20writable=20vs=20read-only.=20Unsu?= =?UTF-8?q?pported=20types=20(UDTs=20/=20function=20blocks=20/=20arrays=20?= =?UTF-8?q?/=20pointers)=20surface=20with=20DataType=3Dnull=20so=20callers?= =?UTF-8?q?=20can=20skip=20or=20recurse.=20TwinCATDriverOptions.EnableCont?= =?UTF-8?q?rollerBrowse=20=E2=80=94=20new=20bool,=20default=20false=20to?= =?UTF-8?q?=20preserve=20the=20strict-config=20path.=20When=20true,=20Disc?= =?UTF-8?q?overAsync=20iterates=20each=20device's=20BrowseSymbolsAsync,=20?= =?UTF-8?q?filters=20via=20TwinCATSystemSymbolFilter=20(rejects=20TwinCAT?= =?UTF-8?q?=5F*,=20Constants.*,=20Mc=5F*,=20=5F=5F*,=20Global=5FVersion*?= =?UTF-8?q?=20prefixes=20+=20anything=20empty),=20skips=20null-DataType=20?= =?UTF-8?q?symbols,=20emits=20surviving=20symbols=20under=20a=20per-device?= =?UTF-8?q?=20Discovered/=20sub-folder=20with=20InstancePath=20as=20both?= =?UTF-8?q?=20FullName=20+=20BrowseName=20+=20ReadOnly=E2=86=92ViewOnly/wr?= =?UTF-8?q?itable=E2=86=92Operate.=20Pre-declared=20tags=20from=20TwinCATD?= =?UTF-8?q?riverOptions.Tags=20always=20emit=20regardless.=20Browse=20fail?= =?UTF-8?q?ure=20is=20non-fatal=20=E2=80=94=20exception=20caught=20+=20swa?= =?UTF-8?q?llowed,=20pre-declared=20tags=20stay=20in=20the=20address=20spa?= =?UTF-8?q?ce,=20operators=20see=20the=20failure=20in=20driver=20health=20?= =?UTF-8?q?on=20next=20read.=20TwinCATSystemSymbolFilter=20static=20class?= =?UTF-8?q?=20mirrors=20AbCipSystemTagFilter's=20shape=20with=20TwinCAT-sp?= =?UTF-8?q?ecific=20prefixes.=20Fake=20client=20updated=20=E2=80=94=20Brow?= =?UTF-8?q?seResults=20list=20for=20test=20setup=20+=20FireNotification-st?= =?UTF-8?q?yle=20single-invocation=20on=20each=20subscribe,=20ThrowOnBrows?= =?UTF-8?q?e=20flag=20for=20failure=20testing.=208=20new=20unit=20tests=20?= =?UTF-8?q?=E2=80=94=20strict=20path=20emits=20only=20pre-declared=20when?= =?UTF-8?q?=20EnableControllerBrowse=3Dfalse,=20browse=20enabled=20adds=20?= =?UTF-8?q?Discovered/=20folder,=20filter=20rejects=20system=20prefixes,?= =?UTF-8?q?=20null-DataType=20symbols=20skipped,=20ReadOnly=20symbols=20su?= =?UTF-8?q?rface=20ViewOnly,=20browse=20failure=20leaves=20pre-declared=20?= =?UTF-8?q?intact,=20SystemSymbolFilter=20theory=20(10=20cases).=20Total?= =?UTF-8?q?=20TwinCAT=20unit=20tests=20now=20110/110=20passing=20(+17=20fr?= =?UTF-8?q?om=20the=20native-notification=20merge's=2093);=20full=20soluti?= =?UTF-8?q?on=20builds=200=20errors;=20other=20drivers=20untouched.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AdsTwinCATClient.cs | 54 +++++ .../ITwinCATClient.cs | 22 ++ .../TwinCATDriver.cs | 40 +++- .../TwinCATDriverOptions.cs | 9 + .../TwinCATSystemSymbolFilter.cs | 32 +++ .../FakeTwinCATClient.cs | 18 ++ .../TwinCATSymbolBrowserTests.cs | 212 ++++++++++++++++++ 7 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSystemSymbolFilter.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolBrowserTests.cs 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) { } } + } +}