diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
index 7018649..1768a6e 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -7,15 +7,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// the skeleton; read / write / discover / subscribe / probe / host-
/// resolver land in PRs 2 and 3.
///
-public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable, IAsyncDisposable
+public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
+ IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
{
private readonly TwinCATDriverOptions _options;
private readonly string _driverInstanceId;
private readonly ITwinCATClientFactory _clientFactory;
+ 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 TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId,
ITwinCATClientFactory? clientFactory = null)
{
@@ -23,6 +28,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable,
_options = options;
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new AdsTwinCATClientFactory();
+ _poll = new PollGroupEngine(
+ reader: ReadAsync,
+ onChange: (handle, tagRef, snapshot) =>
+ OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
}
public string DriverInstanceId => _driverInstanceId;
@@ -41,6 +50,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable,
_devices[device.HostAddress] = new DeviceState(addr, device);
}
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
+
+ if (_options.Probe.Enabled)
+ {
+ 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)
@@ -57,13 +76,19 @@ public sealed class TwinCATDriver : 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.DisposeClient();
+ await _poll.DisposeAsync().ConfigureAwait(false);
+ foreach (var state in _devices.Values)
+ {
+ try { state.ProbeCts?.Cancel(); } catch { }
+ state.ProbeCts?.Dispose();
+ state.ProbeCts = null;
+ state.DisposeClient();
+ }
_devices.Clear();
_tagsByName.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
- return Task.CompletedTask;
}
public DriverHealth GetHealth() => _health;
@@ -183,6 +208,100 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable,
return results;
}
+ // ---- ITagDiscovery ----
+
+ public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ var root = builder.Folder("TwinCAT", "TwinCAT");
+ 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)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ var success = false;
+ try
+ {
+ var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
+ success = await client.ProbeAsync(ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
+ catch
+ {
+ // Probe failure — EnsureConnectedAsync's connect-failure path already disposed
+ // + cleared the client, so next tick will reconnect.
+ }
+
+ TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
+
+ try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { break; }
+ }
+ }
+
+ 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 EnsureConnectedAsync(DeviceState device, CancellationToken ct)
{
if (device.Client is { IsConnected: true } c) return c;
@@ -210,6 +329,11 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, IDisposable,
public TwinCATDeviceOptions Options { get; } = options;
public ITwinCATClient? Client { get; set; }
+ 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 void DisposeClient()
{
Client?.Dispose();
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapabilityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapabilityTests.cs
new file mode 100644
index 0000000..d38f349
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapabilityTests.cs
@@ -0,0 +1,255 @@
+using System.Collections.Concurrent;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class TwinCATCapabilityTests
+{
+ // ---- ITagDiscovery ----
+
+ [Fact]
+ public async Task DiscoverAsync_emits_pre_declared_tags()
+ {
+ var builder = new RecordingBuilder();
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851", DeviceName: "Mach1")],
+ Tags =
+ [
+ new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("Status", "ads://5.23.91.23.1.1:851", "GVL.Status", TwinCATDataType.Bool, Writable: false),
+ ],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, "drv-1");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ await drv.DiscoverAsync(builder, CancellationToken.None);
+
+ builder.Folders.ShouldContain(f => f.BrowseName == "TwinCAT");
+ builder.Folders.ShouldContain(f => f.BrowseName == "ads://5.23.91.23.1.1:851" && f.DisplayName == "Mach1");
+ builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
+ builder.Variables.Single(v => v.BrowseName == "Status").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
+ }
+
+ // ---- ISubscribable ----
+
+ [Fact]
+ public async Task Subscribe_initial_poll_raises_OnDataChange()
+ {
+ var factory = new FakeTwinCATClientFactory
+ {
+ Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 42 } },
+ };
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
+ Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
+ Probe = new TwinCATProbeOptions { 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 ShutdownAsync_cancels_active_subscriptions()
+ {
+ var factory = new FakeTwinCATClientFactory
+ {
+ Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } },
+ };
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
+ Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var events = new ConcurrentQueue();
+ drv.OnDataChange += (_, e) => events.Enqueue(e);
+
+ _ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
+ await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
+ await drv.ShutdownAsync(CancellationToken.None);
+
+ var afterShutdown = events.Count;
+ await Task.Delay(200);
+ events.Count.ShouldBe(afterShutdown);
+ }
+
+ // ---- IHostConnectivityProbe ----
+
+ [Fact]
+ public async Task GetHostStatuses_returns_entry_per_device()
+ {
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices =
+ [
+ new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"),
+ new TwinCATDeviceOptions("ads://5.23.91.24.1.1:851"),
+ ],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, "drv-1");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ drv.GetHostStatuses().Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task Probe_transitions_to_Running_on_successful_probe()
+ {
+ var factory = new FakeTwinCATClientFactory
+ {
+ Customise = () => new FakeTwinCATClient { ProbeResult = true },
+ };
+ var transitions = new ConcurrentQueue();
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
+ Probe = new TwinCATProbeOptions
+ {
+ Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
+ Timeout = TimeSpan.FromMilliseconds(50),
+ },
+ }, "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_probe_failure()
+ {
+ var factory = new FakeTwinCATClientFactory
+ {
+ Customise = () => new FakeTwinCATClient { ProbeResult = false },
+ };
+ var transitions = new ConcurrentQueue();
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
+ Probe = new TwinCATProbeOptions
+ {
+ Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
+ Timeout = TimeSpan.FromMilliseconds(50),
+ },
+ }, "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_Enabled_is_false()
+ {
+ var factory = new FakeTwinCATClientFactory();
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, "drv-1", factory);
+ 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 TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices =
+ [
+ new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"),
+ new TwinCATDeviceOptions("ads://5.23.91.24.1.1:851"),
+ ],
+ Tags =
+ [
+ new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("B", "ads://5.23.91.24.1.1:851", "MAIN.B", TwinCATDataType.DInt),
+ ],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, "drv-1");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ drv.ResolveHost("A").ShouldBe("ads://5.23.91.23.1.1:851");
+ drv.ResolveHost("B").ShouldBe("ads://5.23.91.24.1.1:851");
+ }
+
+ [Fact]
+ public async Task ResolveHost_falls_back_to_first_device_for_unknown_ref()
+ {
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, "drv-1");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ drv.ResolveHost("missing").ShouldBe("ads://5.23.91.23.1.1:851");
+ }
+
+ [Fact]
+ public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
+ {
+ var drv = new TwinCATDriver(new TwinCATDriverOptions(), "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) { } }
+ }
+}