From 400fc6242cca2c67e422bc5b27e45460618ca2e8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 18:02:52 -0400 Subject: [PATCH] =?UTF-8?q?AB=20Legacy=20PR=203=20=E2=80=94=20ITagDiscover?= =?UTF-8?q?y=20+=20ISubscribable=20+=20IHostConnectivityProbe=20+=20IPerCa?= =?UTF-8?q?llHostResolver.=20Fills=20out=20the=20AbLegacy=20capability=20s?= =?UTF-8?q?urface=20=E2=80=94=20the=20driver=20now=20implements=20the=20sa?= =?UTF-8?q?me=207-interface=20set=20as=20AbCip=20(IDriver=20+=20IReadable?= =?UTF-8?q?=20+=20IWritable=20+=20ITagDiscovery=20+=20ISubscribable=20+=20?= =?UTF-8?q?IHostConnectivityProbe=20+=20IPerCallHostResolver).=20ITagDisco?= =?UTF-8?q?very=20emits=20pre-declared=20tags=20under=20an=20AbLegacy=20ro?= =?UTF-8?q?ot=20folder=20with=20a=20per-device=20sub-folder=20keyed=20on?= =?UTF-8?q?=20HostAddress=20(DeviceName=20fallback=20to=20HostAddress=20wh?= =?UTF-8?q?en=20null).=20Writable=20tags=20surface=20as=20SecurityClassifi?= =?UTF-8?q?cation.Operate,=20non-writable=20as=20ViewOnly.=20No=20controll?= =?UTF-8?q?er-side=20enumeration=20=E2=80=94=20PCCC=20has=20no=20@tags=20e?= =?UTF-8?q?quivalent=20on=20SLC=20/=20MicroLogix=20/=20PLC-5=20(symbol=20t?= =?UTF-8?q?able=20isn't=20exposed=20the=20way=20Logix=20exposes=20it),=20s?= =?UTF-8?q?o=20the=20pre-declared=20path=20is=20the=20only=20discovery=20m?= =?UTF-8?q?echanism.=20ISubscribable=20consumes=20the=20shared=20PollGroup?= =?UTF-8?q?Engine=20extracted=20in=20AB=20CIP=20PR=201=20=E2=80=94=20reade?= =?UTF-8?q?r=20delegate=20points=20at=20ReadAsync=20(already=20handles=20l?= =?UTF-8?q?azy=20runtime=20init=20+=20caching),=20onChange=20bridges=20int?= =?UTF-8?q?o=20the=20driver's=20OnDataChange=20event.=20100ms=20interval?= =?UTF-8?q?=20floor.=20Initial-data=20push=20on=20first=20poll.=20Makes=20?= =?UTF-8?q?AbLegacy=20the=20third=20consumer=20of=20PollGroupEngine=20(aft?= =?UTF-8?q?er=20Modbus=20and=20AbCip).=20IHostConnectivityProbe=20?= =?UTF-8?q?=E2=80=94=20per-device=20probe=20loop=20when=20ProbeOptions.Ena?= =?UTF-8?q?bled=20+=20ProbeAddress=20configured=20(defaults=20to=20S:0=20s?= =?UTF-8?q?tatus=20file=20word=200).=20Lazy-init=20on=20first=20tick,=20re?= =?UTF-8?q?-init=20on=20wire=20failure=20(destroyed=20native=20handle=20ge?= =?UTF-8?q?ts=20recreated=20rather=20than=20silently=20staying=20broken).?= =?UTF-8?q?=20Success=20transitions=20device=20to=20Running,=20exception?= =?UTF-8?q?=20to=20Stopped,=20same-state=20spurious=20event=20guard=20unde?= =?UTF-8?q?r=20per-device=20lock.=20GetHostStatuses=20returns=20one=20entr?= =?UTF-8?q?y=20per=20device=20with=20current=20state=20+=20last-change=20t?= =?UTF-8?q?imestamp=20for=20Admin=20/hosts=20surfacing.=20IPerCallHostReso?= =?UTF-8?q?lver=20maps=20tag=20full-ref=20=E2=86=92=20DeviceHostAddress=20?= =?UTF-8?q?for=20the=20Phase=206.1=20(DriverInstanceId,=20ResolvedHostName?= =?UTF-8?q?)=20bulkhead/breaker=20keying=20per=20plan=20decision=20#144.?= =?UTF-8?q?=20Unknown=20refs=20fall=20back=20to=20first=20device's=20addre?= =?UTF-8?q?ss=20(invoker=20handles=20at=20capability=20level=20as=20BadNod?= =?UTF-8?q?eIdUnknown);=20no=20devices=20=E2=86=92=20DriverInstanceId.=20S?= =?UTF-8?q?hutdownAsync=20cancels=20+=20disposes=20each=20probe=20CTS,=20d?= =?UTF-8?q?isposes=20PollGroupEngine=20cancelling=20active=20subscriptions?= =?UTF-8?q?,=20disposes=20every=20cached=20runtime.=20DeviceState=20gains?= =?UTF-8?q?=20ProbeLock=20/=20HostState=20/=20HostStateChangedUtc=20/=20Pr?= =?UTF-8?q?obeCts=20/=20ProbeInitialized=20matching=20AbCip's=20DeviceStat?= =?UTF-8?q?e=20shape.=2010=20new=20unit=20tests=20in=20AbLegacyCapabilityT?= =?UTF-8?q?ests=20covering=20=E2=80=94=20pre-declared=20tags=20emit=20unde?= =?UTF-8?q?r=20AbLegacy/device=20folder=20with=20correct=20SecurityClassif?= =?UTF-8?q?ication,=20subscription=20initial=20poll=20raises=20OnDataChang?= =?UTF-8?q?e=20with=20correct=20value,=20unsubscribe=20halts=20polling=20(?= =?UTF-8?q?value=20change=20post-unsub=20produces=20no=20further=20events)?= =?UTF-8?q?,=20GetHostStatuses=20returns=20one=20entry=20per=20device,=20p?= =?UTF-8?q?robe=20Running=20transition=20on=20successful=20read,=20probe?= =?UTF-8?q?=20Stopped=20transition=20on=20read=20exception,=20probe=20disa?= =?UTF-8?q?bled=20when=20ProbeAddress=20null,=20ResolveHost=20returns=20de?= =?UTF-8?q?clared=20device=20for=20known=20tag,=20falls=20back=20to=20firs?= =?UTF-8?q?t=20device=20for=20unknown,=20falls=20back=20to=20DriverInstanc?= =?UTF-8?q?eId=20when=20no=20devices.=20Total=20AbLegacy=20unit=20tests=20?= =?UTF-8?q?now=2092/92=20passing=20(+10=20from=20PR=202's=2082);=20full=20?= =?UTF-8?q?solution=20builds=200=20errors;=20AbCip=20+=20Modbus=20+=20othe?= =?UTF-8?q?r=20drivers=20untouched.=20AB=20Legacy=20driver=20now=20complet?= =?UTF-8?q?e=20end-to-end=20=E2=80=94=20SLC=20500=20/=20MicroLogix=20/=20P?= =?UTF-8?q?LC-5=20/=20LogixPccc=20all=20shippable=20with=20read=20/=20writ?= =?UTF-8?q?e=20/=20discovery=20/=20subscribe=20/=20probe=20/=20host-resolv?= =?UTF-8?q?e,=20feature-parity=20with=20AbCip=20minus=20IAlarmSource=20(sa?= =?UTF-8?q?me=20deferral=20per=20plan).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AbLegacyDriver.cs | 151 ++++++++++- .../AbLegacyCapabilityTests.cs | 249 ++++++++++++++++++ 2 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyCapabilityTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 23957a7..10b4c9d 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -8,15 +8,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// only at PR 1 time; read / write / discovery / subscribe / probe / /// host-resolver capabilities ship in PRs 2 and 3. /// -public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable +public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, + IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable { private readonly AbLegacyDriverOptions _options; private readonly string _driverInstanceId; private readonly IAbLegacyTagFactory _tagFactory; + private readonly PollGroupEngine _poll; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); + public event EventHandler? OnDataChange; + public event EventHandler? OnHostStatusChanged; + public AbLegacyDriver(AbLegacyDriverOptions options, string driverInstanceId, IAbLegacyTagFactory? tagFactory = null) { @@ -24,6 +29,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable, _options = options; _driverInstanceId = driverInstanceId; _tagFactory = tagFactory ?? new LibplctagLegacyTagFactory(); + _poll = new PollGroupEngine( + reader: ReadAsync, + onChange: (handle, tagRef, snapshot) => + OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot))); } public string DriverInstanceId => _driverInstanceId; @@ -43,6 +52,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable, _devices[device.HostAddress] = new DeviceState(addr, device, profile); } foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag; + + // Probe loops — one per device when enabled + probe address configured. + if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress)) + { + foreach (var state in _devices.Values) + { + state.ProbeCts = new CancellationTokenSource(); + var ct = state.ProbeCts.Token; + _ = Task.Run(() => ProbeLoopAsync(state, ct), ct); + } + } _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } catch (Exception ex) @@ -59,13 +79,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable, await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); } - public Task ShutdownAsync(CancellationToken cancellationToken) + public async Task ShutdownAsync(CancellationToken cancellationToken) { - foreach (var state in _devices.Values) state.DisposeRuntimes(); + await _poll.DisposeAsync().ConfigureAwait(false); + foreach (var state in _devices.Values) + { + try { state.ProbeCts?.Cancel(); } catch { } + state.ProbeCts?.Dispose(); + state.ProbeCts = null; + state.DisposeRuntimes(); + } _devices.Clear(); _tagsByName.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); - return Task.CompletedTask; } public DriverHealth GetHealth() => _health; @@ -194,6 +220,117 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable, return results; } + // ---- ITagDiscovery ---- + + public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(builder); + var root = builder.Folder("AbLegacy", "AbLegacy"); + foreach (var device in _options.Devices) + { + var label = device.DeviceName ?? device.HostAddress; + var deviceFolder = root.Folder(device.HostAddress, label); + var tagsForDevice = _options.Tags.Where(t => + string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); + foreach (var tag in tagsForDevice) + { + deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( + 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)); + } + } + return Task.CompletedTask; + } + + // ---- ISubscribable (polling overlay via shared engine) ---- + + public Task SubscribeAsync( + IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) => + Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval)); + + public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) + { + _poll.Unsubscribe(handle); + return Task.CompletedTask; + } + + // ---- IHostConnectivityProbe ---- + + public IReadOnlyList GetHostStatuses() => + [.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))]; + + private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct) + { + var probeParams = new AbLegacyTagCreateParams( + Gateway: state.ParsedAddress.Gateway, + Port: state.ParsedAddress.Port, + CipPath: state.ParsedAddress.CipPath, + LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute, + TagName: _options.Probe.ProbeAddress!, + Timeout: _options.Probe.Timeout); + + IAbLegacyTagRuntime? probeRuntime = null; + while (!ct.IsCancellationRequested) + { + var success = false; + try + { + probeRuntime ??= _tagFactory.Create(probeParams); + if (!state.ProbeInitialized) + { + await probeRuntime.InitializeAsync(ct).ConfigureAwait(false); + state.ProbeInitialized = true; + } + await probeRuntime.ReadAsync(ct).ConfigureAwait(false); + success = probeRuntime.GetStatus() == 0; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } + catch + { + try { probeRuntime?.Dispose(); } catch { } + probeRuntime = null; + state.ProbeInitialized = false; + } + + TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); + + try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + } + try { probeRuntime?.Dispose(); } catch { } + } + + private void TransitionDeviceState(DeviceState state, HostState newState) + { + HostState old; + lock (state.ProbeLock) + { + old = state.HostState; + if (old == newState) return; + state.HostState = newState; + state.HostStateChangedUtc = DateTime.UtcNow; + } + OnHostStatusChanged?.Invoke(this, + new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState)); + } + + // ---- IPerCallHostResolver ---- + + public string ResolveHost(string fullReference) + { + if (_tagsByName.TryGetValue(fullReference, out var def)) + return def.DeviceHostAddress; + return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId; + } + private async Task EnsureTagRuntimeAsync( DeviceState device, AbLegacyTagDefinition def, CancellationToken ct) { @@ -237,6 +374,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, IDisposable, public Dictionary Runtimes { get; } = new(StringComparer.OrdinalIgnoreCase); + public object ProbeLock { get; } = new(); + public HostState HostState { get; set; } = HostState.Unknown; + public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow; + public CancellationTokenSource? ProbeCts { get; set; } + public bool ProbeInitialized { get; set; } + public void DisposeRuntimes() { foreach (var r in Runtimes.Values) r.Dispose(); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyCapabilityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyCapabilityTests.cs new file mode 100644 index 0000000..38f85e9 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyCapabilityTests.cs @@ -0,0 +1,249 @@ +using System.Collections.Concurrent; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +[Trait("Category", "Unit")] +public sealed class AbLegacyCapabilityTests +{ + // ---- ITagDiscovery ---- + + [Fact] + public async Task DiscoverAsync_emits_pre_declared_tags_under_device_folder() + { + var builder = new RecordingBuilder(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Press-SLC-1")], + Tags = + [ + new AbLegacyTagDefinition("Speed", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int), + new AbLegacyTagDefinition("Temperature", "ab://10.0.0.5/1,0", "F8:0", AbLegacyDataType.Float, Writable: false), + ], + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldContain(f => f.BrowseName == "AbLegacy"); + builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Press-SLC-1"); + 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); + } + + // ---- ISubscribable ---- + + [Fact] + public async Task Subscribe_initial_poll_raises_OnDataChange() + { + var factory = new FakeAbLegacyTagFactory + { + Customise = p => new FakeAbLegacyTag(p) { Value = 42 }, + }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)], + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var events = new ConcurrentQueue(); + drv.OnDataChange += (_, e) => events.Enqueue(e); + + var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None); + await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2)); + + events.First().Snapshot.Value.ShouldBe(42); + await drv.UnsubscribeAsync(handle, CancellationToken.None); + } + + [Fact] + public async Task Unsubscribe_halts_polling() + { + var tagRef = new FakeAbLegacyTag( + new AbLegacyTagCreateParams("10.0.0.5", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2))) { Value = 1 }; + var factory = new FakeAbLegacyTagFactory { Customise = _ => tagRef }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Tags = [new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)], + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var events = new ConcurrentQueue(); + drv.OnDataChange += (_, e) => events.Enqueue(e); + + var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None); + await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1)); + await drv.UnsubscribeAsync(handle, CancellationToken.None); + + var afterUnsub = events.Count; + tagRef.Value = 999; + await Task.Delay(300); + events.Count.ShouldBe(afterUnsub); + } + + // ---- IHostConnectivityProbe ---- + + [Fact] + public async Task GetHostStatuses_returns_one_per_device() + { + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = + [ + new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"), + new AbLegacyDeviceOptions("ab://10.0.0.6/1,0"), + ], + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + drv.GetHostStatuses().Count.ShouldBe(2); + } + + [Fact] + public async Task Probe_transitions_to_Running_on_successful_read() + { + var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) }; + var transitions = new ConcurrentQueue(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Probe = new AbLegacyProbeOptions + { + Enabled = true, Interval = TimeSpan.FromMilliseconds(100), + Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0", + }, + }, "drv-1", factory); + drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e); + + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2)); + + drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Probe_transitions_to_Stopped_on_read_failure() + { + var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { ThrowOnRead = true } }; + var transitions = new ConcurrentQueue(); + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Probe = new AbLegacyProbeOptions + { + Enabled = true, Interval = TimeSpan.FromMilliseconds(100), + Timeout = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0", + }, + }, "drv-1", factory); + drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e); + + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2)); + + drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Probe_disabled_when_ProbeAddress_is_null() + { + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Probe = new AbLegacyProbeOptions { Enabled = true, ProbeAddress = null }, + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + await Task.Delay(200); + + drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown); + await drv.ShutdownAsync(CancellationToken.None); + } + + // ---- IPerCallHostResolver ---- + + [Fact] + public async Task ResolveHost_returns_declared_device_for_known_tag() + { + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = + [ + new AbLegacyDeviceOptions("ab://10.0.0.5/1,0"), + new AbLegacyDeviceOptions("ab://10.0.0.6/1,0"), + ], + Tags = + [ + new AbLegacyTagDefinition("A", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int), + new AbLegacyTagDefinition("B", "ab://10.0.0.6/1,0", "N7:0", AbLegacyDataType.Int), + ], + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + drv.ResolveHost("A").ShouldBe("ab://10.0.0.5/1,0"); + drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0"); + } + + [Fact] + public async Task ResolveHost_falls_back_to_first_device_for_unknown() + { + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")], + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + drv.ResolveHost("missing").ShouldBe("ab://10.0.0.5/1,0"); + } + + [Fact] + public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices() + { + var drv = new AbLegacyDriver(new AbLegacyDriverOptions(), "drv-1"); + await drv.InitializeAsync("{}", CancellationToken.None); + + drv.ResolveHost("anything").ShouldBe("drv-1"); + } + + // ---- helpers ---- + + private static async Task WaitForAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (!condition() && DateTime.UtcNow < deadline) + await Task.Delay(20); + } + + 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) { } } + } +} -- 2.49.1