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