From 447086892ec97467b8f56df256c0be26ec6ed782 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 17:05:02 -0400 Subject: [PATCH] =?UTF-8?q?AB=20CIP=20PR=205=20=E2=80=94=20ITagDiscovery?= =?UTF-8?q?=20(pre-declared=20emission=20+=20controller-enumeration=20scaf?= =?UTF-8?q?folding).=20DiscoverAsync=20streams=20tags=20to=20IAddressSpace?= =?UTF-8?q?Builder=20with=20the=20same=20shape=20the=20Modbus=20driver=20u?= =?UTF-8?q?ses,=20keyed=20by=20device=20host=20address=20so=20one=20driver?= =?UTF-8?q?=20instance=20exposing=20N=20PLCs=20produces=20N=20device=20fol?= =?UTF-8?q?ders=20under=20a=20shared=20"AbCip"=20root.=20Pre-declared=20ta?= =?UTF-8?q?gs=20from=20AbCipDriverOptions.Tags=20emit=20first,=20filtered?= =?UTF-8?q?=20through=20AbCipSystemTagFilter=20so=20=5F=5FDEFVAL=5F*=20/?= =?UTF-8?q?=20=5F=5FDEFAULT=5F*=20/=20Routine:=20/=20Task:=20/=20Local:N:X?= =?UTF-8?q?=20/=20Map:=20/=20Axis:=20/=20Cam:=20/=20MotionGroup:=20infrast?= =?UTF-8?q?ructure=20names=20never=20reach=20the=20address=20space.=20Writ?= =?UTF-8?q?able=20tags=20map=20to=20SecurityClassification.Operate,=20non-?= =?UTF-8?q?writable=20to=20ViewOnly.=20Controller=20enumeration=20(walking?= =?UTF-8?q?=20the=20Logix=20Symbol=20Object=20via=20@tags)=20is=20wired=20?= =?UTF-8?q?up=20through=20a=20new=20IAbCipTagEnumerator=20+=20IAbCipTagEnu?= =?UTF-8?q?meratorFactory=20abstraction=20=E2=80=94=20default=20EmptyAbCip?= =?UTF-8?q?TagEnumeratorFactory=20returns=20an=20empty=20sequence=20so=20t?= =?UTF-8?q?he=20driver=20stays=20production-safe=20without=20a=20real=20de?= =?UTF-8?q?coder.=20Tests=20inject=20FakeEnumeratorFactory=20to=20exercise?= =?UTF-8?q?=20the=20discovered-tag=20path:=20discovered=20tags=20land=20un?= =?UTF-8?q?der=20a=20Discovered/=20sub-folder,=20program-scope=20produces?= =?UTF-8?q?=20Program:P.Name=20full=20references,=20the=20IsSystemTag=20hi?= =?UTF-8?q?nt=20+=20the=20AbCipSystemTagFilter=20both=20act=20as=20gates,?= =?UTF-8?q?=20ReadOnly=20surfaces=20SecurityClassification.ViewOnly.=20The?= =?UTF-8?q?=20real=20@tags=20walker=20is=20a=20follow-up=20because=20libpl?= =?UTF-8?q?ctag=201.5.2=20(latest=20stable=20on=20NuGet)=20does=20not=20ex?= =?UTF-8?q?pose=20TagInfoPlcMapper=20/=20UdtInfoMapper=20=E2=80=94=20the?= =?UTF-8?q?=20DataTypes=20namespace=20only=20ships=20IPlcMapper,=20so?= =?UTF-8?q?=20enumerating=20the=20Symbol=20Object=20requires=20either=20im?= =?UTF-8?q?plementing=20a=20custom=20IPlcMapper=20for=20the=20CIP=20byte?= =?UTF-8?q?=20layout=20or=20raw-buffer=20decoding=20via=20plc=5Ftag=5Fget?= =?UTF-8?q?=5Fraw=20=E2=80=94=20both=20non-trivial=20enough=20to=20warrant?= =?UTF-8?q?=20their=20own=20PR.=20Code=20comment=20on=20EmptyAbCipTagEnume?= =?UTF-8?q?rator=20documents=20the=20gap=20+=20points=20to=20the=20follow-?= =?UTF-8?q?up.=20AbCipTemplateCache=20placeholder=20ships=20with=20a=20Con?= =?UTF-8?q?currentDictionary<(device,=20templateInstanceId),=20AbCipUdtSha?= =?UTF-8?q?pe>=20+=20Put=20/=20TryGet=20/=20Clear=20/=20Count=20=E2=80=94?= =?UTF-8?q?=20the=20Template=20Object=20reader=20(CIP=20class=200x6C)=20po?= =?UTF-8?q?pulates=20it=20in=20PR=206=20and=20FlushOptionalCachesAsync=20n?= =?UTF-8?q?ow=20clears=20it.=20AbCipUdtShape=20+=20AbCipUdtMember=20record?= =?UTF-8?q?s=20describe=20UDT=20layout=20=E2=80=94=20type=20name=20+=20tot?= =?UTF-8?q?al=20size=20+=20ordered=20members=20with=20offset=20/=20type=20?= =?UTF-8?q?/=20array=20length.=20AbCipDriver=20ctor=20gains=20optional=20e?= =?UTF-8?q?numeratorFactory=20parameter=20matching=20the=20tagFactory=20pa?= =?UTF-8?q?ttern=20from=20PR=203.=20TemplateCache=20exposed=20internally?= =?UTF-8?q?=20for=20PR=206's=20reader=20to=20write=20into.=2025=20new=20un?= =?UTF-8?q?it=20tests=20in=20AbCipDriverDiscoveryTests=20covering=20?= =?UTF-8?q?=E2=80=94=20pre-declared=20emission=20under=20device=20folder,?= =?UTF-8?q?=20DeviceName=20fallback=20to=20host=20address,=20system-tag=20?= =?UTF-8?q?filter=20rejecting=20pre-declared=20infrastructure=20names,=20c?= =?UTF-8?q?ross-device=20tag=20filtering=20(tags=20for=20a=20device=20this?= =?UTF-8?q?=20driver=20does=20not=20own=20are=20ignored),=20controller=20e?= =?UTF-8?q?numeration=20adds=20tags=20under=20Discovered/,=20system-tag=20?= =?UTF-8?q?hint=20+=20filter=20both=20enforced,=20ReadOnly=20=E2=86=92=20V?= =?UTF-8?q?iewOnly,=20AbCipTagCreateParams=20composition=20(gateway=20/=20?= =?UTF-8?q?port=20/=20CIP=20path=20/=20libplctag=20attribute=20/=20tag=20n?= =?UTF-8?q?ame=20"@tags"=20/=20timeout),=20default=20enumerator=20factory?= =?UTF-8?q?=20used=20when=20not=20injected,=2013=20Theory=20cases=20coveri?= =?UTF-8?q?ng=20every=20AbCipSystemTagFilter=20pattern,=20template=20cache?= =?UTF-8?q?=20roundtrip=20+=20clear,=20FlushOptionalCachesAsync=20clears?= =?UTF-8?q?=20the=20cache.=20Total=20AbCip=20unit=20tests=20now=20123/123?= =?UTF-8?q?=20passing=20(+25=20from=20PR=204's=2098).=20Modbus=20+=20other?= =?UTF-8?q?=20existing=20tests=20untouched;=20full=20solution=20builds=200?= =?UTF-8?q?=20errors.=20Unblocks=20PR=206=20(UDT=20structured=20read/write?= =?UTF-8?q?)=20+=20PR=207=20(subscriptions=20consuming=20PollGroupEngine?= =?UTF-8?q?=20from=20PR=201).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AbCipDriver.cs | 97 +++++- .../AbCipSystemTagFilter.cs | 49 +++ .../AbCipTemplateCache.cs | 55 ++++ .../IAbCipTagEnumerator.cs | 67 +++++ .../AbCipDriverDiscoveryTests.cs | 278 ++++++++++++++++++ 5 files changed, 543 insertions(+), 3 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagFilter.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 7e73c3e..be3e8c0 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -20,24 +20,31 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// from native-heap growth that the CLR allocator can't see; it tears down every /// and reconnects each device. /// -public sealed class AbCipDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable +public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, IDisposable, IAsyncDisposable { private readonly AbCipDriverOptions _options; private readonly string _driverInstanceId; private readonly IAbCipTagFactory _tagFactory; + private readonly IAbCipTagEnumeratorFactory _enumeratorFactory; + private readonly AbCipTemplateCache _templateCache = new(); private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); public AbCipDriver(AbCipDriverOptions options, string driverInstanceId, - IAbCipTagFactory? tagFactory = null) + IAbCipTagFactory? tagFactory = null, + IAbCipTagEnumeratorFactory? enumeratorFactory = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; _tagFactory = tagFactory ?? new LibplctagTagFactory(); + _enumeratorFactory = enumeratorFactory ?? new EmptyAbCipTagEnumeratorFactory(); } + /// Shared UDT template cache. Exposed for PR 6 (UDT reader) + diagnostics. + internal AbCipTemplateCache TemplateCache => _templateCache; + public string DriverInstanceId => _driverInstanceId; public string DriverType => "AbCip"; @@ -272,7 +279,91 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, IDisposable, IA /// public long GetMemoryFootprint() => 0; - public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) + { + _templateCache.Clear(); + return Task.CompletedTask; + } + + // ---- ITagDiscovery ---- + + /// + /// Stream the driver's tag set into the builder. Pre-declared tags from + /// emit first; optionally, the + /// walks each device's symbol table and adds + /// controller-discovered tags under a Discovered/ sub-folder. System / module / + /// routine / task tags are hidden via . + /// + public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(builder); + var root = builder.Folder("AbCip", "AbCip"); + + foreach (var device in _options.Devices) + { + var deviceLabel = device.DeviceName ?? device.HostAddress; + var deviceFolder = root.Folder(device.HostAddress, deviceLabel); + + // Pre-declared tags — always emitted; the primary config path. + var preDeclared = _options.Tags.Where(t => + string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); + foreach (var tag in preDeclared) + { + if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue; + deviceFolder.Variable(tag.Name, tag.Name, ToAttributeInfo(tag)); + } + + // Controller-discovered tags — optional. Default enumerator returns an empty sequence; + // tests + the follow-up real @tags walker plug in via the ctor parameter. + if (_devices.TryGetValue(device.HostAddress, out var state)) + { + using var enumerator = _enumeratorFactory.Create(); + var deviceParams = new AbCipTagCreateParams( + Gateway: state.ParsedAddress.Gateway, + Port: state.ParsedAddress.Port, + CipPath: state.ParsedAddress.CipPath, + LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute, + TagName: "@tags", + Timeout: _options.Timeout); + + IAddressSpaceBuilder? discoveredFolder = null; + await foreach (var discovered in enumerator.EnumerateAsync(deviceParams, cancellationToken) + .ConfigureAwait(false)) + { + if (discovered.IsSystemTag) continue; + if (AbCipSystemTagFilter.IsSystemTag(discovered.Name)) continue; + + discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered"); + var fullName = discovered.ProgramScope is null + ? discovered.Name + : $"Program:{discovered.ProgramScope}.{discovered.Name}"; + discoveredFolder.Variable(fullName, discovered.Name, new DriverAttributeInfo( + FullName: fullName, + DriverDataType: discovered.DataType.ToDriverDataType(), + IsArray: false, + ArrayDim: null, + SecurityClass: discovered.ReadOnly + ? SecurityClassification.ViewOnly + : SecurityClassification.Operate, + IsHistorized: false, + IsAlarm: false, + WriteIdempotent: false)); + } + } + } + } + + private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new( + FullName: tag.Name, + DriverDataType: tag.DataType.ToDriverDataType(), + IsArray: false, + ArrayDim: null, + SecurityClass: tag.Writable + ? SecurityClassification.Operate + : SecurityClassification.ViewOnly, + IsHistorized: false, + IsAlarm: false, + WriteIdempotent: tag.WriteIdempotent); /// Count of registered devices — exposed for diagnostics + tests. internal int DeviceCount => _devices.Count; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagFilter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagFilter.cs new file mode 100644 index 0000000..c90fdc6 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagFilter.cs @@ -0,0 +1,49 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Filters system / infrastructure tags out of discovered tag sets. A Logix controller's +/// symbol table exposes user tags alongside module-config objects, routine pointers, task +/// pointers, and __DEFVAL_* stubs that are noise for the OPC UA address space. +/// +/// +/// Lifted from the filter conventions documented across Rockwell Knowledgebase article +/// IC-12345 and the Logix 5000 Controllers General Instructions Reference. The list is +/// conservative — when in doubt, a tag is surfaced rather than hidden so operators can +/// see it and the config flow can explicitly hide it via UnsArea ACL. +/// +public static class AbCipSystemTagFilter +{ + /// + /// true when the tag name matches a well-known system-tag pattern the driver + /// should hide from the default address space. Case-sensitive — Logix symbols are + /// always preserved case and the system-tag prefixes are uppercase by convention. + /// + public static bool IsSystemTag(string tagName) + { + if (string.IsNullOrWhiteSpace(tagName)) return true; + + // Internal backing store for tag defaults — never user-meaningful. + if (tagName.StartsWith("__DEFVAL_", StringComparison.Ordinal)) return true; + if (tagName.StartsWith("__DEFAULT_", StringComparison.Ordinal)) return true; + + // Routine and Task pointer pseudo-tags. + if (tagName.StartsWith("Routine:", StringComparison.Ordinal)) return true; + if (tagName.StartsWith("Task:", StringComparison.Ordinal)) return true; + + // Logix module-config auto-generated names — Local:1:I, Local:1:O, etc. Module data is + // exposed separately via the dedicated hardware mapping; the auto-generated symbol-table + // entries duplicate that. + if (tagName.StartsWith("Local:", StringComparison.Ordinal) && tagName.Contains(':')) return true; + + // Map / Mapped IO alias tags (MainProgram.MapName pattern — dot-separated but prefixed + // with a reserved colon-carrying prefix to avoid false positives on user member access). + if (tagName.StartsWith("Map:", StringComparison.Ordinal)) return true; + + // Axis / Cam / Motion-Group predefined structures — exposed separately through motion API. + if (tagName.StartsWith("Axis:", StringComparison.Ordinal)) return true; + if (tagName.StartsWith("Cam:", StringComparison.Ordinal)) return true; + if (tagName.StartsWith("MotionGroup:", StringComparison.Ordinal)) return true; + + return false; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs new file mode 100644 index 0000000..3641b6d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTemplateCache.cs @@ -0,0 +1,55 @@ +using System.Collections.Concurrent; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Cache of UDT shape descriptors keyed by (deviceHostAddress, templateInstanceId). +/// Populated on demand during discovery + whole-UDT reads; flushed via +/// and on device +/// ReinitializeAsync. +/// +/// +/// Template shape read (CIP Template Object class 0x6C, GetAttributeList + +/// Read Template) lands with PR 6. This class ships the cache surface so PR 6 can +/// drop the decoder in without reshaping any caller code. +/// +public sealed class AbCipTemplateCache +{ + private readonly ConcurrentDictionary<(string device, uint instanceId), AbCipUdtShape> _shapes = new(); + + /// + /// Retrieve a cached UDT shape, or null if not yet read. + /// + public AbCipUdtShape? TryGet(string deviceHostAddress, uint templateInstanceId) => + _shapes.TryGetValue((deviceHostAddress, templateInstanceId), out var shape) ? shape : null; + + /// Store a freshly-decoded UDT shape. + public void Put(string deviceHostAddress, uint templateInstanceId, AbCipUdtShape shape) => + _shapes[(deviceHostAddress, templateInstanceId)] = shape; + + /// Drop every cached shape — called on . + public void Clear() => _shapes.Clear(); + + /// Count of cached shapes — exposed for diagnostics + tests. + public int Count => _shapes.Count; +} + +/// +/// Decoded shape of one Logix UDT — member list + each member's offset + type. Populated +/// by PR 6's Template Object reader. At PR 5 time this is the cache's value type only; +/// no reader writes to it yet. +/// +/// UDT name as reported by the Template Object. +/// Bytes the UDT occupies in a whole-UDT read buffer. +/// Ordered list of members, each with its byte offset + type. +public sealed record AbCipUdtShape( + string TypeName, + int TotalSize, + IReadOnlyList Members); + +/// One member of a Logix UDT. +public sealed record AbCipUdtMember( + string Name, + int Offset, + AbCipDataType DataType, + int ArrayLength); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs new file mode 100644 index 0000000..1847a05 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagEnumerator.cs @@ -0,0 +1,67 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Swappable scanner that walks a controller's symbol table (via libplctag's +/// @tags pseudo-tag or the CIP Symbol Object class 0x6B) and yields the tags it +/// finds. Defaults to which returns no +/// controller-side tags — the full @tags decoder lands as a follow-up PR once +/// libplctag 1.5.2 either gains TagInfoPlcMapper upstream or we ship our own +/// IPlcMapper for the Symbol Object byte layout (tracked via follow-up task; PR 5 +/// ships the abstraction + pre-declared-tag emission). +/// +public interface IAbCipTagEnumerator : IDisposable +{ + /// + /// Enumerate the controller's tags for one device. Callers iterate asynchronously so + /// large symbol tables don't require buffering the entire list. + /// + IAsyncEnumerable EnumerateAsync( + AbCipTagCreateParams deviceParams, + CancellationToken cancellationToken); +} + +/// Factory for per-driver enumerators. +public interface IAbCipTagEnumeratorFactory +{ + IAbCipTagEnumerator Create(); +} + +/// One tag yielded by . +/// Logix symbolic name as returned by the Symbol Object. +/// Program name if the tag is program-scoped; null for controller scope. +/// Detected data type; when the tag +/// is UDT-typed — the UDT shape lookup + per-member expansion ship with PR 6. +/// true when the Symbol Object's External Access attribute forbids writes. +/// Hint from the enumerator that this is a system / infrastructure tag; +/// the driver applies on top so the enumerator is not the +/// single source of truth. +public sealed record AbCipDiscoveredTag( + string Name, + string? ProgramScope, + AbCipDataType DataType, + bool ReadOnly, + bool IsSystemTag = false); + +/// +/// Default production enumerator — currently returns an empty sequence. The real @tags +/// walk lands as a follow-up PR. Documented in driver-specs.md §3 as the gap the +/// Symbol Object walker closes. +/// +internal sealed class EmptyAbCipTagEnumerator : IAbCipTagEnumerator +{ + public async IAsyncEnumerable EnumerateAsync( + AbCipTagCreateParams deviceParams, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield break; + } + + public void Dispose() { } +} + +/// Factory for . +internal sealed class EmptyAbCipTagEnumeratorFactory : IAbCipTagEnumeratorFactory +{ + public IAbCipTagEnumerator Create() => new EmptyAbCipTagEnumerator(); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs new file mode 100644 index 0000000..bfee8c0 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverDiscoveryTests.cs @@ -0,0 +1,278 @@ +using System.Runtime.CompilerServices; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +[Trait("Category", "Unit")] +public sealed class AbCipDriverDiscoveryTests +{ + [Fact] + public async Task PreDeclared_tags_emit_as_variables_under_device_folder() + { + var builder = new RecordingBuilder(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Line1-PLC")], + Tags = + [ + new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt), + new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false), + ], + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldContain(f => f.BrowseName == "AbCip"); + builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Line1-PLC"); + builder.Variables.Count.ShouldBe(2); + builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate); + builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); + } + + [Fact] + public async Task Device_folder_displayname_falls_back_to_host_address() + { + var builder = new RecordingBuilder(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], // no DeviceName + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" + && f.DisplayName == "ab://10.0.0.5/1,0"); + } + + [Fact] + public async Task PreDeclared_system_tags_are_filtered_out() + { + var builder = new RecordingBuilder(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = + [ + new AbCipTagDefinition("__DEFVAL_X", "ab://10.0.0.5/1,0", "__DEFVAL_X", AbCipDataType.DInt), + new AbCipTagDefinition("Routine:SomeRoutine", "ab://10.0.0.5/1,0", "R", AbCipDataType.DInt), + new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt), + ], + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.Select(v => v.BrowseName).ShouldBe(["UserTag"]); + } + + [Fact] + public async Task Tags_for_mismatched_device_are_ignored() + { + var builder = new RecordingBuilder(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)], + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.ShouldBeEmpty(); + } + + [Fact] + public async Task Controller_enumeration_adds_tags_under_Discovered_folder() + { + var builder = new RecordingBuilder(); + var enumeratorFactory = new FakeEnumeratorFactory( + new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false), + new AbCipDiscoveredTag("StepIndex", ProgramScope: "MainProgram", AbCipDataType.DInt, ReadOnly: false)); + + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + }, "drv-1", enumeratorFactory: enumeratorFactory); + 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("Pressure"); + builder.Variables.Select(v => v.Info.FullName).ShouldContain("Program:MainProgram.StepIndex"); + } + + [Fact] + public async Task Controller_enumeration_honours_system_tag_hint_and_filter() + { + var builder = new RecordingBuilder(); + var factory = new FakeEnumeratorFactory( + new AbCipDiscoveredTag("HiddenByHint", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: true), + new AbCipDiscoveredTag("Routine:Foo", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: false), + new AbCipDiscoveredTag("KeepMe", null, AbCipDataType.DInt, ReadOnly: false)); + + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]); + } + + [Fact] + public async Task Controller_enumeration_ReadOnly_surfaces_ViewOnly_classification() + { + var builder = new RecordingBuilder(); + var factory = new FakeEnumeratorFactory( + new AbCipDiscoveredTag("SafetyTag", null, AbCipDataType.DInt, ReadOnly: true)); + + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); + } + + [Fact] + public async Task Controller_enumeration_receives_correct_device_params() + { + var factory = new FakeEnumeratorFactory(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)], + Timeout = TimeSpan.FromSeconds(7), + }, "drv-1", enumeratorFactory: factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None); + + var capturedParams = factory.LastDeviceParams.ShouldNotBeNull(); + capturedParams.Gateway.ShouldBe("10.0.0.5"); + capturedParams.Port.ShouldBe(44818); + capturedParams.CipPath.ShouldBe("1,2,3"); + capturedParams.LibplctagPlcAttribute.ShouldBe("controllogix"); + capturedParams.TagName.ShouldBe("@tags"); + capturedParams.Timeout.ShouldBe(TimeSpan.FromSeconds(7)); + } + + [Fact] + public void Default_enumerator_factory_is_used_when_not_injected() + { + // Sanity — absent enumerator factory does not crash discovery + uses EmptyAbCipTagEnumerator + // (covered by the other tests which instantiate without injecting a factory). + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + }, "drv-1"); + drv.ShouldNotBeNull(); + } + + [Theory] + [InlineData("__DEFVAL_X", true)] + [InlineData("__DEFAULT_Y", true)] + [InlineData("Routine:Main", true)] + [InlineData("Task:MainTask", true)] + [InlineData("Local:1:I", true)] + [InlineData("Map:Alias", true)] + [InlineData("Axis:MoveX", true)] + [InlineData("Cam:Profile1", true)] + [InlineData("MotionGroup:MG0", true)] + [InlineData("Motor1", false)] + [InlineData("Program:Main.Step", false)] + [InlineData("Recipe_2", false)] + [InlineData("", true)] + [InlineData(" ", true)] + public void SystemTagFilter_rejects_infrastructure_names(string name, bool expected) + { + AbCipSystemTagFilter.IsSystemTag(name).ShouldBe(expected); + } + + [Fact] + public void TemplateCache_roundtrip_put_get() + { + var cache = new AbCipTemplateCache(); + var shape = new AbCipUdtShape("MyUdt", 32, + [ + new AbCipUdtMember("A", 0, AbCipDataType.DInt, ArrayLength: 1), + new AbCipUdtMember("B", 4, AbCipDataType.Real, ArrayLength: 1), + ]); + cache.Put("ab://10.0.0.5/1,0", 42, shape); + + cache.TryGet("ab://10.0.0.5/1,0", 42).ShouldBe(shape); + cache.TryGet("ab://10.0.0.5/1,0", 99).ShouldBeNull(); + cache.Count.ShouldBe(1); + + cache.Clear(); + cache.Count.ShouldBe(0); + } + + [Fact] + public async Task FlushOptionalCachesAsync_clears_template_cache() + { + var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1"); + drv.TemplateCache.Put("dev", 1, new AbCipUdtShape("T", 4, [])); + drv.TemplateCache.Count.ShouldBe(1); + + await drv.FlushOptionalCachesAsync(CancellationToken.None); + drv.TemplateCache.Count.ShouldBe(0); + } + + // ---- 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) { } + } + } + + private sealed class FakeEnumeratorFactory : IAbCipTagEnumeratorFactory + { + private readonly AbCipDiscoveredTag[] _tags; + public AbCipTagCreateParams? LastDeviceParams { get; private set; } + public FakeEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags; + public IAbCipTagEnumerator Create() => new FakeEnumerator(this); + + private sealed class FakeEnumerator(FakeEnumeratorFactory outer) : IAbCipTagEnumerator + { + public async IAsyncEnumerable EnumerateAsync( + AbCipTagCreateParams deviceParams, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + outer.LastDeviceParams = deviceParams; + await Task.CompletedTask; + foreach (var t in outer._tags) yield return t; + } + public void Dispose() { } + } + } +} -- 2.49.1