Compare commits
2 Commits
twincat-pr
...
twincat-na
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c5b202910 | ||
| a0112ddb43 |
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using TwinCAT.Ads;
|
using TwinCAT.Ads;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
@@ -17,6 +18,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
|||||||
internal sealed class AdsTwinCATClient : ITwinCATClient
|
internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||||
{
|
{
|
||||||
private readonly AdsClient _client = new();
|
private readonly AdsClient _client = new();
|
||||||
|
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
|
||||||
|
|
||||||
|
public AdsTwinCATClient()
|
||||||
|
{
|
||||||
|
_client.AdsNotificationEx += OnAdsNotificationEx;
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsConnected => _client.IsConnected;
|
public bool IsConnected => _client.IsConnected;
|
||||||
|
|
||||||
@@ -95,7 +102,79 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => _client.Dispose();
|
public async Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||||
|
string symbolPath,
|
||||||
|
TwinCATDataType type,
|
||||||
|
int? bitIndex,
|
||||||
|
TimeSpan cycleTime,
|
||||||
|
Action<string, object?> 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<ResultHandle>; 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<string, object?> onChange,
|
||||||
|
AdsTwinCATClient owner,
|
||||||
|
uint handle) : ITwinCATNotificationHandle
|
||||||
|
{
|
||||||
|
public string SymbolPath { get; } = symbolPath;
|
||||||
|
public TwinCATDataType Type { get; } = type;
|
||||||
|
public int? BitIndex { get; } = bitIndex;
|
||||||
|
public Action<string, object?> 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
|
private static Type MapToClrType(TwinCATDataType type) => type switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -46,8 +46,31 @@ public interface ITwinCATClient : IDisposable
|
|||||||
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
|
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register a cyclic / on-change ADS notification for a symbol. Returns a handle whose
|
||||||
|
/// <see cref="IDisposable.Dispose"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="symbolPath">ADS symbol path (e.g. <c>MAIN.bStart</c>).</param>
|
||||||
|
/// <param name="type">Declared type; drives the native layout + callback value boxing.</param>
|
||||||
|
/// <param name="bitIndex">For BOOL-within-word tags — the bit to extract from the parent word.</param>
|
||||||
|
/// <param name="cycleTime">Minimum interval between change notifications (native-floor depends on target).</param>
|
||||||
|
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
|
||||||
|
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
|
||||||
|
Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||||
|
string symbolPath,
|
||||||
|
TwinCATDataType type,
|
||||||
|
int? bitIndex,
|
||||||
|
TimeSpan cycleTime,
|
||||||
|
Action<string, object?> onChange,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Opaque handle for a registered ADS notification. <see cref="IDisposable.Dispose"/> tears it down.</summary>
|
||||||
|
public interface ITwinCATNotificationHandle : IDisposable { }
|
||||||
|
|
||||||
/// <summary>Factory for <see cref="ITwinCATClient"/>s. One client per device.</summary>
|
/// <summary>Factory for <see cref="ITwinCATClient"/>s. One client per device.</summary>
|
||||||
public interface ITwinCATClientFactory
|
public interface ITwinCATClientFactory
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
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)
|
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);
|
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||||
foreach (var state in _devices.Values)
|
foreach (var state in _devices.Values)
|
||||||
{
|
{
|
||||||
@@ -238,18 +245,83 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
// ---- ISubscribable (native ADS notifications with poll fallback) ----
|
||||||
|
|
||||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
private readonly ConcurrentDictionary<long, NativeSubscription> _nativeSubs = new();
|
||||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
private long _nextNativeSubId;
|
||||||
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribe via native ADS notifications when <see cref="TwinCATDriverOptions.UseNativeNotifications"/>
|
||||||
|
/// is <c>true</c>, otherwise fall through to the shared <see cref="PollGroupEngine"/>.
|
||||||
|
/// Native path registers one <see cref="ITwinCATNotificationHandle"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
|
IReadOnlyList<string> 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<ITwinCATNotificationHandle>(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)
|
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);
|
_poll.Unsubscribe(handle);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record NativeSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||||
|
{
|
||||||
|
public string DiagnosticId => $"twincat-native-sub-{Id}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record NativeSubscription(
|
||||||
|
NativeSubscriptionHandle Handle,
|
||||||
|
IReadOnlyList<ITwinCATNotificationHandle> Registrations);
|
||||||
|
|
||||||
// ---- IHostConnectivityProbe ----
|
// ---- IHostConnectivityProbe ----
|
||||||
|
|
||||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ public sealed class TwinCATDriverOptions
|
|||||||
public IReadOnlyList<TwinCATTagDefinition> Tags { get; init; } = [];
|
public IReadOnlyList<TwinCATTagDefinition> Tags { get; init; } = [];
|
||||||
public TwinCATProbeOptions Probe { get; init; } = new();
|
public TwinCATProbeOptions Probe { get; init; } = new();
|
||||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When <c>true</c> (default), <c>SubscribeAsync</c> registers native ADS notifications
|
||||||
|
/// via <c>AddDeviceNotificationExAsync</c> — 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 <c>false</c>, the driver falls through to the shared
|
||||||
|
/// <see cref="Core.Abstractions.PollGroupEngine"/> — same semantics as the other
|
||||||
|
/// libplctag-backed drivers. Set <c>false</c> for deployments where the AMS router has
|
||||||
|
/// notification limits you can't raise.
|
||||||
|
/// </summary>
|
||||||
|
public bool UseNativeNotifications { get; init; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -56,6 +56,48 @@ internal class FakeTwinCATClient : ITwinCATClient
|
|||||||
DisposeCount++;
|
DisposeCount++;
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- notification fake ----
|
||||||
|
|
||||||
|
public List<FakeNotification> Notifications { get; } = new();
|
||||||
|
public bool ThrowOnAddNotification { get; set; }
|
||||||
|
|
||||||
|
public virtual Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||||
|
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
||||||
|
Action<string, object?> 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<ITwinCATNotificationHandle>(reg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fire a change event through the registered callback for <paramref name="symbolPath"/>.</summary>
|
||||||
|
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<string, object?> onChange, FakeTwinCATClient owner) : ITwinCATNotificationHandle
|
||||||
|
{
|
||||||
|
public string SymbolPath { get; } = symbolPath;
|
||||||
|
public TwinCATDataType Type { get; } = type;
|
||||||
|
public int? BitIndex { get; } = bitIndex;
|
||||||
|
public Action<string, object?> OnChange { get; } = onChange;
|
||||||
|
public bool Disposed { get; private set; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Disposed = true;
|
||||||
|
owner.Notifications.Remove(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class FakeTwinCATClientFactory : ITwinCATClientFactory
|
internal sealed class FakeTwinCATClientFactory : ITwinCATClientFactory
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public sealed class TwinCATCapabilityTests
|
|||||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
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)],
|
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
|
||||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
UseNativeNotifications = false, // poll-mode test
|
||||||
}, "drv-1", factory);
|
}, "drv-1", factory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ public sealed class TwinCATCapabilityTests
|
|||||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
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)],
|
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
|
||||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
UseNativeNotifications = false, // poll-mode test
|
||||||
}, "drv-1", factory);
|
}, "drv-1", factory);
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ public sealed class TwinCATDriverTests
|
|||||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
{
|
{
|
||||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
}, "drv-1");
|
}, "drv-1");
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ public sealed class TwinCATDriverTests
|
|||||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
{
|
{
|
||||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
}, "drv-1");
|
}, "drv-1");
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
await drv.ReinitializeAsync("{}", CancellationToken.None);
|
await drv.ReinitializeAsync("{}", CancellationToken.None);
|
||||||
|
|||||||
@@ -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<DataChangeEventArgs>();
|
||||||
|
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<DataChangeEventArgs>();
|
||||||
|
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<InvalidOperationException>(() =>
|
||||||
|
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<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||||
|
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
||||||
|
Action<string, object?> 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<DataChangeEventArgs>();
|
||||||
|
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<bool> condition, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (!condition() && DateTime.UtcNow < deadline)
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user