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