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