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() { } + } + } +}