diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs index 8336d20..bbf86c6 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using TwinCAT.Ads; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; @@ -17,6 +18,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; internal sealed class AdsTwinCATClient : ITwinCATClient { private readonly AdsClient _client = new(); + private readonly ConcurrentDictionary _notifications = new(); + + public AdsTwinCATClient() + { + _client.AdsNotificationEx += OnAdsNotificationEx; + } public bool IsConnected => _client.IsConnected; @@ -95,7 +102,79 @@ internal sealed class AdsTwinCATClient : ITwinCATClient } } - public void Dispose() => _client.Dispose(); + public async Task AddNotificationAsync( + string symbolPath, + TwinCATDataType type, + int? bitIndex, + TimeSpan cycleTime, + Action onChange, + CancellationToken cancellationToken) + { + var clrType = MapToClrType(type); + // NotificationSettings takes cycle + max-delay in 100ns units. AdsTransMode.OnChange + // fires when the value differs; OnCycle fires every cycle. OnChange is the right default + // for OPC UA data-change semantics — the PLC already has the best view of "has this + // changed" so we let it decide. + var cycleTicks = (uint)Math.Max(1, cycleTime.Ticks / TimeSpan.TicksPerMillisecond * 10_000); + var settings = new NotificationSettings(AdsTransMode.OnChange, (int)cycleTicks, 0); + + // AddDeviceNotificationExAsync returns Task; AdsNotificationEx fires + // with the handle as part of the event args so we use the handle as the correlation + // key into _notifications. + var result = await _client.AddDeviceNotificationExAsync( + symbolPath, settings, userData: null, clrType, args: null, cancellationToken) + .ConfigureAwait(false); + if (result.ErrorCode != AdsErrorCode.NoError) + throw new InvalidOperationException( + $"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}"); + + var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle); + _notifications[result.Handle] = reg; + return reg; + } + + private void OnAdsNotificationEx(object? sender, AdsNotificationExEventArgs args) + { + if (!_notifications.TryGetValue(args.Handle, out var reg)) return; + var value = args.Value; + if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool) + value = ExtractBit(value, bit); + try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ } + } + + internal async Task DeleteNotificationAsync(uint handle, CancellationToken cancellationToken) + { + _notifications.TryRemove(handle, out _); + try { await _client.DeleteDeviceNotificationAsync(handle, cancellationToken).ConfigureAwait(false); } + catch { /* best-effort tear-down; target may already be gone */ } + } + + public void Dispose() + { + _client.AdsNotificationEx -= OnAdsNotificationEx; + _notifications.Clear(); + _client.Dispose(); + } + + private sealed class NotificationRegistration( + string symbolPath, + TwinCATDataType type, + int? bitIndex, + Action onChange, + AdsTwinCATClient owner, + uint handle) : ITwinCATNotificationHandle + { + public string SymbolPath { get; } = symbolPath; + public TwinCATDataType Type { get; } = type; + public int? BitIndex { get; } = bitIndex; + public Action OnChange { get; } = onChange; + + public void Dispose() + { + // Fire-and-forget AMS call — caller has already committed to the tear-down. + _ = owner.DeleteNotificationAsync(handle, CancellationToken.None); + } + } private static Type MapToClrType(TwinCATDataType type) => type switch { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs index cc6a086..66e19c0 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs @@ -46,8 +46,31 @@ public interface ITwinCATClient : IDisposable /// Used by 's probe loop. /// Task ProbeAsync(CancellationToken cancellationToken); + + /// + /// Register a cyclic / on-change ADS notification for a symbol. Returns a handle whose + /// tears the notification down. Callback fires on the + /// thread libplctag / AdsClient uses for notifications — consumers should marshal to + /// their own scheduler before doing work of any size. + /// + /// ADS symbol path (e.g. MAIN.bStart). + /// Declared type; drives the native layout + callback value boxing. + /// For BOOL-within-word tags — the bit to extract from the parent word. + /// Minimum interval between change notifications (native-floor depends on target). + /// Invoked with (symbolPath, boxedValue) per notification. + /// Cancels the initial registration; does not tear down an established notification. + Task AddNotificationAsync( + string symbolPath, + TwinCATDataType type, + int? bitIndex, + TimeSpan cycleTime, + Action onChange, + CancellationToken cancellationToken); } +/// Opaque handle for a registered ADS notification. tears it down. +public interface ITwinCATNotificationHandle : IDisposable { } + /// Factory for s. One client per device. public interface ITwinCATClientFactory { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index 1768a6e..145b575 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; @@ -78,6 +79,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery public async Task ShutdownAsync(CancellationToken cancellationToken) { + // Native subs first — disposing the handles is cheap + lets the client close its + // notifications before the AdsClient itself goes away. + foreach (var sub in _nativeSubs.Values) + foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } } + _nativeSubs.Clear(); + await _poll.DisposeAsync().ConfigureAwait(false); foreach (var state in _devices.Values) { @@ -238,18 +245,83 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery return Task.CompletedTask; } - // ---- ISubscribable (polling overlay via shared engine) ---- + // ---- ISubscribable (native ADS notifications with poll fallback) ---- - public Task SubscribeAsync( - IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) => - Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval)); + private readonly ConcurrentDictionary _nativeSubs = new(); + private long _nextNativeSubId; + + /// + /// Subscribe via native ADS notifications when + /// is true, otherwise fall through to the shared . + /// Native path registers one per tag against the + /// target's PLC runtime — the PLC pushes changes on its own cycle so we skip the poll + /// loop entirely. Unsub path disposes the handles. + /// + public async Task SubscribeAsync( + IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) + { + if (!_options.UseNativeNotifications) + return _poll.Subscribe(fullReferences, publishingInterval); + + var id = Interlocked.Increment(ref _nextNativeSubId); + var handle = new NativeSubscriptionHandle(id); + var registrations = new List(fullReferences.Count); + var now = DateTime.UtcNow; + + try + { + foreach (var reference in fullReferences) + { + if (!_tagsByName.TryGetValue(reference, out var def)) continue; + if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue; + + var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); + var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); + var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; + var bitIndex = parsed?.BitIndex; + + var reg = await client.AddNotificationAsync( + symbolName, def.DataType, bitIndex, publishingInterval, + (_, value) => OnDataChange?.Invoke(this, + new DataChangeEventArgs(handle, reference, new DataValueSnapshot( + value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))), + cancellationToken).ConfigureAwait(false); + registrations.Add(reg); + } + } + catch + { + // On any registration failure, tear down everything we got so far + rethrow. Leaves + // the subscription in a clean "never existed" state rather than a half-registered + // state the caller has to clean up. + foreach (var r in registrations) { try { r.Dispose(); } catch { } } + throw; + } + + _nativeSubs[id] = new NativeSubscription(handle, registrations); + return handle; + } public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { + if (handle is NativeSubscriptionHandle native && _nativeSubs.TryRemove(native.Id, out var sub)) + { + foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } } + return Task.CompletedTask; + } _poll.Unsubscribe(handle); return Task.CompletedTask; } + private sealed record NativeSubscriptionHandle(long Id) : ISubscriptionHandle + { + public string DiagnosticId => $"twincat-native-sub-{Id}"; + } + + private sealed record NativeSubscription( + NativeSubscriptionHandle Handle, + IReadOnlyList Registrations); + // ---- IHostConnectivityProbe ---- public IReadOnlyList GetHostStatuses() => diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs index 4a0a8f4..be0671e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs @@ -11,6 +11,18 @@ public sealed class TwinCATDriverOptions public IReadOnlyList Tags { get; init; } = []; public TwinCATProbeOptions Probe { get; init; } = new(); public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2); + + /// + /// When true (default), SubscribeAsync registers native ADS notifications + /// via AddDeviceNotificationExAsync — the PLC pushes changes on its own cycle + /// rather than the driver polling. Strictly better for latency + CPU when the target + /// supports it (TC2 + TC3 PLC runtimes always do; some soft-PLC / third-party ADS + /// implementations may not). When false, the driver falls through to the shared + /// — same semantics as the other + /// libplctag-backed drivers. Set false for deployments where the AMS router has + /// notification limits you can't raise. + /// + public bool UseNativeNotifications { get; init; } = true; } /// diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs index 51598ef..f3bec53 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs @@ -56,6 +56,48 @@ internal class FakeTwinCATClient : ITwinCATClient DisposeCount++; IsConnected = false; } + + // ---- notification fake ---- + + public List Notifications { get; } = new(); + public bool ThrowOnAddNotification { get; set; } + + public virtual Task AddNotificationAsync( + string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime, + Action onChange, CancellationToken cancellationToken) + { + if (ThrowOnAddNotification) + throw Exception ?? new InvalidOperationException("fake AddNotification failure"); + + var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this); + Notifications.Add(reg); + return Task.FromResult(reg); + } + + /// Fire a change event through the registered callback for . + public void FireNotification(string symbolPath, object? value) + { + foreach (var n in Notifications) + if (!n.Disposed && string.Equals(n.SymbolPath, symbolPath, StringComparison.OrdinalIgnoreCase)) + n.OnChange(symbolPath, value); + } + + public sealed class FakeNotification( + string symbolPath, TwinCATDataType type, int? bitIndex, + Action onChange, FakeTwinCATClient owner) : ITwinCATNotificationHandle + { + public string SymbolPath { get; } = symbolPath; + public TwinCATDataType Type { get; } = type; + public int? BitIndex { get; } = bitIndex; + public Action OnChange { get; } = onChange; + public bool Disposed { get; private set; } + + public void Dispose() + { + Disposed = true; + owner.Notifications.Remove(this); + } + } } internal sealed class FakeTwinCATClientFactory : ITwinCATClientFactory diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapabilityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapabilityTests.cs index d38f349..21d482a 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapabilityTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapabilityTests.cs @@ -49,6 +49,7 @@ public sealed class TwinCATCapabilityTests 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 }, + UseNativeNotifications = false, // poll-mode test }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); @@ -74,6 +75,7 @@ public sealed class TwinCATCapabilityTests 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 }, + UseNativeNotifications = false, // poll-mode test }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs index fd0e6bf..0b264fc 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs @@ -54,6 +54,7 @@ public sealed class TwinCATDriverTests 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); @@ -68,6 +69,7 @@ public sealed class TwinCATDriverTests 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); await drv.ReinitializeAsync("{}", CancellationToken.None); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs new file mode 100644 index 0000000..119fd5b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs @@ -0,0 +1,221 @@ +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 TwinCATNativeNotificationTests +{ + private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewNativeDriver(params TwinCATTagDefinition[] tags) + { + var factory = new FakeTwinCATClientFactory(); + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], + Tags = tags, + Probe = new TwinCATProbeOptions { Enabled = false }, + UseNativeNotifications = true, + }, "drv-1", factory); + return (drv, factory); + } + + [Fact] + public async Task Native_subscribe_registers_one_notification_per_tag() + { + var (drv, factory) = NewNativeDriver( + new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt), + new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.Real)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None); + handle.DiagnosticId.ShouldStartWith("twincat-native-sub-"); + + factory.Clients[0].Notifications.Count.ShouldBe(2); + factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.B"], ignoreOrder: true); + } + + [Fact] + public async Task Native_notification_fires_OnDataChange_with_pushed_value() + { + var (drv, factory) = NewNativeDriver( + new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var events = new ConcurrentQueue(); + drv.OnDataChange += (_, e) => events.Enqueue(e); + + _ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None); + + factory.Clients[0].FireNotification("MAIN.Speed", 4200); + factory.Clients[0].FireNotification("MAIN.Speed", 4201); + + events.Count.ShouldBe(2); + events.Last().Snapshot.Value.ShouldBe(4201); + events.Last().FullReference.ShouldBe("Speed"); // driver-side reference, not ADS symbol + } + + [Fact] + public async Task Native_unsubscribe_disposes_all_notifications() + { + var (drv, factory) = NewNativeDriver( + new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt), + new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None); + factory.Clients[0].Notifications.Count.ShouldBe(2); + + await drv.UnsubscribeAsync(handle, CancellationToken.None); + factory.Clients[0].Notifications.ShouldBeEmpty(); + } + + [Fact] + public async Task Native_unsubscribe_halts_future_notifications() + { + var (drv, factory) = NewNativeDriver( + new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); + 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); + factory.Clients[0].FireNotification("MAIN.X", 1); + var snapshotFake = factory.Clients[0]; + await drv.UnsubscribeAsync(handle, CancellationToken.None); + + var afterUnsub = events.Count; + // After unsubscribe the fake's Notifications list is empty so FireNotification finds nothing + // to invoke. This mirrors the production contract — disposed handles no longer deliver. + snapshotFake.FireNotification("MAIN.X", 999); + events.Count.ShouldBe(afterUnsub); + } + + [Fact] + public async Task Native_subscribe_failure_mid_registration_cleans_up_partial_state() + { + // Fail-on-second-call fake — first AddNotificationAsync succeeds, second throws. + // Subscribe's catch block must tear the first one down before rethrowing so no zombie + // notification lingers. + var fake = new FailAfterNAddsFake(new AbTagParamsIrrelevant(), succeedBefore: 1); + var factory = new FakeTwinCATClientFactory { Customise = () => fake }; + var drv = new TwinCATDriver(new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.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.23.1.1:851", "MAIN.B", TwinCATDataType.DInt), + ], + Probe = new TwinCATProbeOptions { Enabled = false }, + UseNativeNotifications = true, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await Should.ThrowAsync(() => + drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None)); + + // First registration succeeded then got torn down by the catch; second threw. + fake.AddCallCount.ShouldBe(2); + fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up + } + + private sealed class AbTagParamsIrrelevant { } + + private sealed class FailAfterNAddsFake : FakeTwinCATClient + { + private readonly int _succeedBefore; + public int AddCallCount { get; private set; } + + public FailAfterNAddsFake(AbTagParamsIrrelevant _, int succeedBefore) : base() + { + _succeedBefore = succeedBefore; + } + + public override Task AddNotificationAsync( + string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime, + Action onChange, CancellationToken cancellationToken) + { + AddCallCount++; + if (AddCallCount > _succeedBefore) + throw new InvalidOperationException($"fake fail on call #{AddCallCount}"); + return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, onChange, cancellationToken); + } + } + + [Fact] + public async Task Native_shutdown_disposes_subscriptions() + { + var (drv, factory) = NewNativeDriver( + new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + + _ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None); + factory.Clients[0].Notifications.Count.ShouldBe(1); + + await drv.ShutdownAsync(CancellationToken.None); + factory.Clients[0].Notifications.ShouldBeEmpty(); + } + + [Fact] + public async Task Poll_path_still_works_when_UseNativeNotifications_false() + { + var factory = new FakeTwinCATClientFactory + { + Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 7 } }, + }; + 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 }, + UseNativeNotifications = 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(150), CancellationToken.None); + await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2)); + + events.First().Snapshot.Value.ShouldBe(7); + factory.Clients[0].Notifications.ShouldBeEmpty(); // no native notifications on poll path + await drv.UnsubscribeAsync(handle, CancellationToken.None); + } + + [Fact] + public async Task Subscribe_handle_DiagnosticId_indicates_native_vs_poll() + { + var (drvNative, _) = NewNativeDriver( + new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)); + await drvNative.InitializeAsync("{}", CancellationToken.None); + var nativeHandle = await drvNative.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None); + nativeHandle.DiagnosticId.ShouldContain("native"); + + var factoryPoll = new FakeTwinCATClientFactory + { + Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } }, + }; + var drvPoll = 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 }, + UseNativeNotifications = false, + }, "drv-1", factoryPoll); + await drvPoll.InitializeAsync("{}", CancellationToken.None); + var pollHandle = await drvPoll.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None); + pollHandle.DiagnosticId.ShouldNotContain("native"); + } + + private static async Task WaitForAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (!condition() && DateTime.UtcNow < deadline) + await Task.Delay(20); + } +}