885 lines
41 KiB
C#
885 lines
41 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Runtime.CompilerServices;
|
||
using System.Text;
|
||
using TwinCAT;
|
||
using TwinCAT.Ads;
|
||
using TwinCAT.Ads.SumCommand;
|
||
using TwinCAT.Ads.TypeSystem;
|
||
using TwinCAT.TypeSystem;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||
|
||
/// <summary>
|
||
/// Default <see cref="ITwinCATClient"/> backed by Beckhoff's <see cref="AdsClient"/>.
|
||
/// One instance per AMS target; reused across reads / writes / probes.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>Wire behavior depends on a reachable AMS router — on Windows the router comes
|
||
/// from TwinCAT XAR; elsewhere from the <c>Beckhoff.TwinCAT.Ads.TcpRouter</c> package
|
||
/// hosted by the server process. Neither is built-in here; deployment wires one in.</para>
|
||
///
|
||
/// <para>Error mapping — ADS error codes surface through <see cref="AdsErrorException"/>
|
||
/// and get translated to OPC UA status codes via <see cref="TwinCATStatusMapper.MapAdsError"/>.</para>
|
||
/// </remarks>
|
||
internal sealed class AdsTwinCATClient : ITwinCATClient
|
||
{
|
||
private readonly AdsClient _client = new();
|
||
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
|
||
|
||
// Per-parent-symbol RMW locks. Keys are bounded by the writable-bit-tag cardinality
|
||
// and are intentionally never removed — a leaking-but-bounded dictionary is simpler
|
||
// than tracking liveness, matching the AbCip / Modbus / FOCAS pattern from #181.
|
||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _bitWriteLocks = new();
|
||
|
||
// PR 2.2 — handle cache. Per-tag read/write resolves a symbolic path to an ADS
|
||
// variable handle once, then issues every subsequent op against the handle. Smaller
|
||
// AMS payloads (4-byte handle vs N-byte path) + skips name resolution in the runtime.
|
||
// Lifetime is process-scoped: cleared on reconnect (EnsureConnected path), wiped on
|
||
// a Symbol-Version-Invalid retry, and disposed on Dispose. PR 2.3 will wire a
|
||
// proactive Symbol Version invalidation listener so stale handles after an online
|
||
// change get evicted before the next read fails — until then, operators can call
|
||
// FlushOptionalCachesAsync to wipe manually.
|
||
private readonly ConcurrentDictionary<string, uint> _handleCache = new();
|
||
private bool _wasConnected;
|
||
private readonly object _connectionStateGate = new();
|
||
|
||
// PR 2.3 — proactive Symbol-Version invalidation listener. The Beckhoff stack
|
||
// surfaces a high-level <see cref="AdsClient.AdsSymbolVersionChanged"/> event
|
||
// (built on top of the SymbolVersion ADS notification, IndexGroup 0xF008) that
|
||
// fires when the PLC's symbol table version counter increments — i.e. on full
|
||
// re-initialisations after a download / activate. Registered after the AMS
|
||
// session is up so the device server actually accepts the registration; we
|
||
// unregister + clear the handle on Dispose. _symbolVersionRegistered guards
|
||
// against double-registration if EnsureSymbolVersionListenerAsync is called
|
||
// re-entrantly through ConnectAsync on a reconnect.
|
||
//
|
||
// Spec deviation: the original PR 2.3 plan called for a raw
|
||
// AddDeviceNotificationAsync(AdsReservedIndexGroup.SymbolVersion, ...). Beckhoff
|
||
// wrap that in IAdsSymbolChangedProvider on AdsClient so we get a typed
|
||
// <see cref="AdsSymbolVersionChangedEventArgs"/> + Dispose-aware unregister
|
||
// for free — same wire effect, smaller surface area.
|
||
private bool _symbolVersionRegistered;
|
||
private long _symbolVersionBumps;
|
||
|
||
// Test-only counter — number of CreateVariableHandleAsync calls actually issued
|
||
// (i.e. cache misses). Integration tests assert this stays at the unique-symbol
|
||
// count after a second pass over the same set.
|
||
internal int HandleCreateCount;
|
||
|
||
/// <summary>Test-only — current size of the handle cache.</summary>
|
||
internal int HandleCacheCount => _handleCache.Count;
|
||
|
||
/// <summary>Test-only — total Symbol-Version bumps observed since process start.</summary>
|
||
internal long SymbolVersionBumps => Interlocked.Read(ref _symbolVersionBumps);
|
||
|
||
public AdsTwinCATClient()
|
||
{
|
||
_client.AdsNotificationEx += OnAdsNotificationEx;
|
||
_client.AdsSymbolVersionChanged += OnAdsSymbolVersionChanged;
|
||
}
|
||
|
||
public bool IsConnected => _client.IsConnected;
|
||
|
||
public async Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||
{
|
||
if (_client.IsConnected)
|
||
{
|
||
// Idempotent. Still ensure the Symbol-Version listener is registered — first
|
||
// ConnectAsync may have lost the registration if the AMS session dropped.
|
||
await EnsureSymbolVersionListenerAsync(cancellationToken).ConfigureAwait(false);
|
||
return;
|
||
}
|
||
_client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds);
|
||
var netId = AmsNetId.Parse(address.NetId);
|
||
|
||
// PR 2.2 — a fresh AMS session invalidates every cached handle (handle space is
|
||
// per-session in the ADS device server). Clear before reconnect so any read that
|
||
// raced with a transient drop never reuses a stale handle from the prior session.
|
||
// Note: the handles for the prior session are gone with that session — no need to
|
||
// call DeleteVariableHandleAsync, which would just fail with a transport error.
|
||
var wasConnected = false;
|
||
lock (_connectionStateGate)
|
||
{
|
||
wasConnected = _wasConnected;
|
||
_wasConnected = false;
|
||
}
|
||
if (wasConnected || !_handleCache.IsEmpty)
|
||
_handleCache.Clear();
|
||
|
||
// PR 2.3 — a reconnect drops the device-side notification registration. Mark
|
||
// the listener as needing re-registration so EnsureSymbolVersionListenerAsync
|
||
// re-arms it against the new session.
|
||
_symbolVersionRegistered = false;
|
||
|
||
_client.Connect(netId, address.Port);
|
||
|
||
lock (_connectionStateGate) _wasConnected = _client.IsConnected;
|
||
|
||
// PR 2.3 — register the Symbol-Version listener now that the AMS session is up.
|
||
// Best-effort: a registration failure here doesn't fail the connect (the
|
||
// DeviceSymbolVersionInvalid evict-and-retry path from PR 2.2 stays as the safety
|
||
// net), it just means we won't get proactive cache invalidation until next reconnect.
|
||
await EnsureSymbolVersionListenerAsync(cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// PR 2.3 — register the Beckhoff <c>AdsSymbolVersionChanged</c> event listener
|
||
/// against the current AMS session. Idempotent: a second call while
|
||
/// <see cref="_symbolVersionRegistered"/> is <c>true</c> is a no-op so reconnect
|
||
/// paths can call this freely without double-arming. Failures swallowed because
|
||
/// the PR 2.2 reactive evict-and-retry path is still in place — proactive
|
||
/// invalidation is an optimisation, not a correctness requirement.
|
||
/// </summary>
|
||
private async Task EnsureSymbolVersionListenerAsync(CancellationToken cancellationToken)
|
||
{
|
||
if (_symbolVersionRegistered) return;
|
||
try
|
||
{
|
||
await _client.RegisterSymbolVersionChangedAsync(OnAdsSymbolVersionChanged, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
_symbolVersionRegistered = true;
|
||
}
|
||
catch (OperationCanceledException) { throw; }
|
||
catch
|
||
{
|
||
// Best-effort. The reactive evict-and-retry path (PR 2.2) catches the same
|
||
// staleness; this is just an optimisation that lets us preempt the wasted
|
||
// request that would otherwise come back DeviceSymbolVersionInvalid.
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// PR 2.3 — Beckhoff fires this when the PLC's symbol-version counter increments,
|
||
/// which happens on every full re-initialisation (download, activate-config, etc.).
|
||
/// Every cached handle is invalid against the new symbol table, so we wipe the
|
||
/// cache here. In-flight reads that already hold a handle will fall through to the
|
||
/// PR 2.2 <see cref="AdsErrorCode.DeviceSymbolVersionInvalid"/> evict-and-retry path,
|
||
/// which is exactly what we want — the proactive wipe just preempts the wasted
|
||
/// round-trip on the next read for any symbol that didn't already have an in-flight op.
|
||
/// </summary>
|
||
private void OnAdsSymbolVersionChanged(object? sender, AdsSymbolVersionChangedEventArgs e)
|
||
{
|
||
Interlocked.Increment(ref _symbolVersionBumps);
|
||
// Snapshot cache for best-effort wire-side cleanup, then clear so the next
|
||
// EnsureHandleAsync re-resolves. Wire deletes are fire-and-forget — the device
|
||
// server has already invalidated these handles, so the deletes typically just
|
||
// bounce back with an error code we don't care about.
|
||
var snapshot = _handleCache.ToArray();
|
||
_handleCache.Clear();
|
||
foreach (var kv in snapshot)
|
||
{
|
||
try { _ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None); }
|
||
catch { /* best-effort; the new symbol-table version makes these handles dead anyway */ }
|
||
}
|
||
}
|
||
|
||
public async Task<(object? value, uint status)> ReadValueAsync(
|
||
string symbolPath,
|
||
TwinCATDataType type,
|
||
int? bitIndex,
|
||
int[]? arrayDimensions,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
var clrType = MapToClrType(type);
|
||
var readType = IsWholeArray(arrayDimensions) ? clrType.MakeArrayType() : clrType;
|
||
|
||
// PR 2.2 — handle-based read. EnsureHandleAsync resolves through the cache;
|
||
// SymbolVersionInvalid evicts + retries once with a fresh handle.
|
||
var (rawValue, errorCode) = await ReadByHandleWithRetryAsync(symbolPath, readType, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
if (errorCode != AdsErrorCode.NoError)
|
||
return (null, TwinCATStatusMapper.MapAdsError((uint)errorCode));
|
||
|
||
var value = rawValue;
|
||
if (IsWholeArray(arrayDimensions))
|
||
{
|
||
value = PostProcessArray(type, value);
|
||
return (value, TwinCATStatusMapper.Good);
|
||
}
|
||
|
||
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
|
||
value = ExtractBit(value, bit);
|
||
value = PostProcessIecTime(type, value);
|
||
|
||
return (value, TwinCATStatusMapper.Good);
|
||
}
|
||
catch (AdsErrorException ex)
|
||
{
|
||
return (null, TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolve <paramref name="symbolPath"/> to a cached ADS variable handle (or create one
|
||
/// on first use) and dispatch a <see cref="AdsClient.ReadAnyAsync(uint, Type, CancellationToken)"/>.
|
||
/// On <see cref="AdsErrorCode.DeviceSymbolVersionInvalid"/> evicts the cached handle
|
||
/// + retries once with a freshly-created handle — covers the online-change race where
|
||
/// the symbol survives but its descriptor moves.
|
||
/// </summary>
|
||
private async Task<(object? value, AdsErrorCode errorCode)> ReadByHandleWithRetryAsync(
|
||
string symbolPath, Type readType, CancellationToken cancellationToken)
|
||
{
|
||
var handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||
var result = await _client.ReadAnyAsync(handle, readType, cancellationToken).ConfigureAwait(false);
|
||
if (result.ErrorCode == AdsErrorCode.DeviceSymbolVersionInvalid)
|
||
{
|
||
EvictHandle(symbolPath);
|
||
handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||
result = await _client.ReadAnyAsync(handle, readType, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
return (result.Value, result.ErrorCode);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mirror of <see cref="ReadByHandleWithRetryAsync"/> for writes. Returns the final
|
||
/// <see cref="AdsErrorCode"/>; the caller maps that to an OPC UA status.
|
||
/// </summary>
|
||
private async Task<AdsErrorCode> WriteByHandleWithRetryAsync(
|
||
string symbolPath, object value, CancellationToken cancellationToken)
|
||
{
|
||
var handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||
var result = await _client.WriteAnyAsync(handle, value, cancellationToken).ConfigureAwait(false);
|
||
if (result.ErrorCode == AdsErrorCode.DeviceSymbolVersionInvalid)
|
||
{
|
||
EvictHandle(symbolPath);
|
||
handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||
result = await _client.WriteAnyAsync(handle, value, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
return result.ErrorCode;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Lookup-or-create the cached ADS handle for <paramref name="symbolPath"/>. The
|
||
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> guarantees publication safety,
|
||
/// but two concurrent callers on a cold key may both call
|
||
/// <see cref="AdsClient.CreateVariableHandleAsync(string, CancellationToken)"/>.
|
||
/// The loser's handle leaks for the lifetime of the process — acceptable cost
|
||
/// given how narrow the race window is, and matched by the libplctag / S7 driver
|
||
/// handle-cache patterns.
|
||
/// </summary>
|
||
internal async ValueTask<uint> EnsureHandleAsync(string symbolPath, CancellationToken cancellationToken)
|
||
{
|
||
if (_handleCache.TryGetValue(symbolPath, out var existing))
|
||
return existing;
|
||
|
||
Interlocked.Increment(ref HandleCreateCount);
|
||
var result = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||
throw new AdsErrorException(
|
||
$"CreateVariableHandleAsync failed for '{symbolPath}'", result.ErrorCode);
|
||
|
||
// GetOrAdd on a hit returns the winning handle; a loser-side DeleteVariableHandle here
|
||
// would race against an in-flight read using that handle elsewhere in this method, so
|
||
// we accept the small leak (one-time, per cold key) instead.
|
||
return _handleCache.GetOrAdd(symbolPath, result.Handle);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Evict a single cached handle. Best-effort delete on the wire — the runtime may
|
||
/// already have invalidated the handle (Symbol-Version-Invalid path), so we swallow
|
||
/// transport / ADS errors here.
|
||
/// </summary>
|
||
private void EvictHandle(string symbolPath)
|
||
{
|
||
if (!_handleCache.TryRemove(symbolPath, out var handle)) return;
|
||
try
|
||
{
|
||
// Fire-and-forget delete — the cache key is gone, the wire-side cleanup is
|
||
// strictly courtesy. If the device server is in a state where the handle is
|
||
// already dead, the delete will fail and we don't care.
|
||
_ = _client.DeleteVariableHandleAsync(handle, CancellationToken.None);
|
||
}
|
||
catch
|
||
{
|
||
// Best-effort.
|
||
}
|
||
}
|
||
|
||
private static bool IsWholeArray(int[]? arrayDimensions) =>
|
||
arrayDimensions is { Length: > 0 } && arrayDimensions.All(d => d > 0);
|
||
|
||
/// <summary>Apply per-element IEC TIME/DATE post-processing to a flat array result.</summary>
|
||
private static object? PostProcessArray(TwinCATDataType type, object? value)
|
||
{
|
||
if (value is not Array arr) return value;
|
||
var elementProjector = type switch
|
||
{
|
||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay
|
||
or TwinCATDataType.Date or TwinCATDataType.DateTime
|
||
=> (Func<object?, object?>)(v => PostProcessIecTime(type, v)),
|
||
_ => null,
|
||
};
|
||
if (elementProjector is null) return arr;
|
||
// IEC time post-processing changes the CLR element type (uint -> TimeSpan / DateTime).
|
||
// Project into an object[] so the array element type matches the projected values.
|
||
var projected = new object?[arr.Length];
|
||
for (var i = 0; i < arr.Length; i++)
|
||
projected[i] = elementProjector(arr.GetValue(i));
|
||
return projected;
|
||
}
|
||
|
||
public async Task<uint> WriteValueAsync(
|
||
string symbolPath,
|
||
TwinCATDataType type,
|
||
int? bitIndex,
|
||
int[]? arrayDimensions,
|
||
object? value,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (IsWholeArray(arrayDimensions))
|
||
return TwinCATStatusMapper.BadNotSupported; // PR-1.4 ships read-only whole-array
|
||
|
||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||
return await WriteBitInWordAsync(symbolPath, bit, value, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
|
||
try
|
||
{
|
||
var converted = ConvertForWrite(type, value);
|
||
// PR 2.2 — handle-based write with SymbolVersionInvalid evict-and-retry.
|
||
var errorCode = await WriteByHandleWithRetryAsync(symbolPath, converted, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
return errorCode == AdsErrorCode.NoError
|
||
? TwinCATStatusMapper.Good
|
||
: TwinCATStatusMapper.MapAdsError((uint)errorCode);
|
||
}
|
||
catch (AdsErrorException ex)
|
||
{
|
||
return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read-modify-write a single bit within an integer parent word. <paramref name="symbolPath"/>
|
||
/// is the bit-selector path (e.g. <c>Flags.3</c>); the parent is the same path with the
|
||
/// <c>.N</c> suffix stripped and is read/written as a UDINT — TwinCAT handles narrower
|
||
/// parents (BYTE/WORD) implicitly through the UDINT projection.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Concurrent bit writers against the same parent are serialised through a per-parent
|
||
/// <see cref="SemaphoreSlim"/> to prevent torn reads/writes. Mirrors the AbCip / Modbus /
|
||
/// FOCAS bit-RMW pattern.
|
||
/// </remarks>
|
||
private async Task<uint> WriteBitInWordAsync(
|
||
string symbolPath, int bit, object? value, CancellationToken cancellationToken)
|
||
{
|
||
var parentPath = TryGetParentSymbolPath(symbolPath);
|
||
if (parentPath is null) return TwinCATStatusMapper.BadNotSupported;
|
||
|
||
var setBit = Convert.ToBoolean(value);
|
||
var rmwLock = _bitWriteLocks.GetOrAdd(parentPath, _ => new SemaphoreSlim(1, 1));
|
||
await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||
try
|
||
{
|
||
// PR 2.2 — RMW round-trip flows through the same handle cache so that the
|
||
// parent word's resolved handle is reused on subsequent bit writes too.
|
||
var (rawCurrent, readErr) = await ReadByHandleWithRetryAsync(parentPath, typeof(uint), cancellationToken)
|
||
.ConfigureAwait(false);
|
||
if (readErr != AdsErrorCode.NoError)
|
||
return TwinCATStatusMapper.MapAdsError((uint)readErr);
|
||
|
||
var current = Convert.ToUInt32(rawCurrent ?? 0u);
|
||
var updated = ApplyBit(current, bit, setBit);
|
||
|
||
var writeErr = await WriteByHandleWithRetryAsync(parentPath, updated, cancellationToken)
|
||
.ConfigureAwait(false);
|
||
return writeErr == AdsErrorCode.NoError
|
||
? TwinCATStatusMapper.Good
|
||
: TwinCATStatusMapper.MapAdsError((uint)writeErr);
|
||
}
|
||
catch (AdsErrorException ex)
|
||
{
|
||
return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||
}
|
||
finally
|
||
{
|
||
rmwLock.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Strip the trailing <c>.N</c> bit selector from a TwinCAT symbol path. Returns
|
||
/// <c>null</c> when the path has no parent (single segment / leading dot).
|
||
/// </summary>
|
||
internal static string? TryGetParentSymbolPath(string symbolPath)
|
||
{
|
||
var dot = symbolPath.LastIndexOf('.');
|
||
return dot <= 0 ? null : symbolPath.Substring(0, dot);
|
||
}
|
||
|
||
/// <summary>Set or clear bit <paramref name="bit"/> in <paramref name="word"/>.</summary>
|
||
internal static uint ApplyBit(uint word, int bit, bool setBit)
|
||
{
|
||
var mask = 1u << bit;
|
||
return setBit ? (word | mask) : (word & ~mask);
|
||
}
|
||
|
||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
var state = await _client.ReadStateAsync(cancellationToken).ConfigureAwait(false);
|
||
return state.ErrorCode == AdsErrorCode.NoError;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
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);
|
||
value = PostProcessIecTime(reg.Type, value);
|
||
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 async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
|
||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||
{
|
||
// SymbolLoaderFactory downloads the symbol-info blob once then iterates locally — the
|
||
// async surface on this interface is for our callers, not for the underlying call which
|
||
// is effectively sync on top of the already-open AdsClient.
|
||
var settings = new SymbolLoaderSettings(SymbolsLoadMode.Flat);
|
||
var loader = SymbolLoaderFactory.Create(_client, settings);
|
||
await Task.Yield(); // honors the async surface; pragmatic given the loader itself is sync
|
||
|
||
foreach (ISymbol symbol in loader.Symbols)
|
||
{
|
||
if (cancellationToken.IsCancellationRequested) yield break;
|
||
var mapped = ResolveSymbolDataType(symbol.DataType);
|
||
var readOnly = !IsSymbolWritable(symbol);
|
||
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolve an IEC atomic <see cref="TwinCATDataType"/> for a TwinCAT symbol's data type.
|
||
/// ENUMs surface as their underlying integer (the enum's <c>BaseType</c>); ALIAS chains
|
||
/// are walked recursively via <see cref="IAliasType.BaseType"/> until an atomic primitive
|
||
/// is reached. POINTER / REFERENCE / INTERFACE / UNION / STRUCT / FB / array types remain
|
||
/// out of scope and surface as <c>null</c> so the caller skips them.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Recursion is bounded at <see cref="MaxAliasDepth"/> as a defence against pathological
|
||
/// cycles in the type graph — TwinCAT shouldn't emit those, but this is cheap insurance.
|
||
/// </remarks>
|
||
internal const int MaxAliasDepth = 16;
|
||
|
||
internal static TwinCATDataType? ResolveSymbolDataType(IDataType? dataType)
|
||
{
|
||
var current = dataType;
|
||
for (var depth = 0; current is not null && depth < MaxAliasDepth; depth++)
|
||
{
|
||
switch (current.Category)
|
||
{
|
||
case DataTypeCategory.Primitive:
|
||
case DataTypeCategory.String:
|
||
return MapSymbolTypeName(current.Name);
|
||
case DataTypeCategory.Enum:
|
||
case DataTypeCategory.Alias:
|
||
// IEnumType : IAliasType, so BaseType walk handles both. For an enum the
|
||
// base type is the underlying integer; for alias chains it's the next link.
|
||
if (current is IAliasType alias) { current = alias.BaseType; continue; }
|
||
return null;
|
||
default:
|
||
// POINTER / REFERENCE / INTERFACE / UNION / STRUCT / ARRAY / FB / Program —
|
||
// explicitly out of scope at this PR.
|
||
return null;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch
|
||
{
|
||
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
||
"SINT" or "BYTE" => TwinCATDataType.SInt,
|
||
"USINT" => TwinCATDataType.USInt,
|
||
"INT" or "WORD" => TwinCATDataType.Int,
|
||
"UINT" => TwinCATDataType.UInt,
|
||
"DINT" or "DWORD" => TwinCATDataType.DInt,
|
||
"UDINT" => TwinCATDataType.UDInt,
|
||
"LINT" or "LWORD" => TwinCATDataType.LInt,
|
||
"ULINT" => TwinCATDataType.ULInt,
|
||
"REAL" => TwinCATDataType.Real,
|
||
"LREAL" => TwinCATDataType.LReal,
|
||
"STRING" => TwinCATDataType.String,
|
||
"WSTRING" => TwinCATDataType.WString,
|
||
"TIME" => TwinCATDataType.Time,
|
||
"DATE" => TwinCATDataType.Date,
|
||
"DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime,
|
||
"TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay,
|
||
_ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope
|
||
};
|
||
|
||
private static bool IsSymbolWritable(ISymbol symbol)
|
||
{
|
||
// SymbolAccessRights is a flags enum — the Write bit indicates a writable symbol.
|
||
// When the symbol implementation doesn't surface it, assume writable + let the PLC
|
||
// return AccessDenied at write time.
|
||
if (symbol is Symbol s) return (s.AccessRights & SymbolAccessRights.Write) != 0;
|
||
return true;
|
||
}
|
||
|
||
public async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
|
||
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
|
||
{
|
||
if (reads.Count == 0) return Array.Empty<(object?, uint)>();
|
||
|
||
// PR 2.2 deviation: bulk path stays on symbolic Sum-command (SumInstancePathAnyTypeRead /
|
||
// SumWriteBySymbolPath). Beckhoff also exposes SumHandleRead / SumWriteByHandle, but
|
||
// wiring the cached handles into them changes the request layout substantially +
|
||
// would either need to reuse handles created on the per-tag path (tying lifetimes)
|
||
// or maintain a parallel handle batch — neither pulls weight against PR 2.1's already
|
||
// 10-20× win. Tracked as a follow-up for the Phase-2 perf sweep.
|
||
// Build the (path, AnyTypeSpecifier) request envelope. SumInstancePathAnyTypeRead
|
||
// batches all paths into a single ADS Sum-read round-trip (IndexGroup 0xF080 = read
|
||
// multiple items by symbol name with ANY-type marshalling).
|
||
var typeSpecs = new List<(string instancePath, AnyTypeSpecifier spec)>(reads.Count);
|
||
foreach (var r in reads)
|
||
typeSpecs.Add((r.SymbolPath, BuildAnyTypeSpecifier(r.Type, r.StringLength)));
|
||
|
||
var sumCmd = new SumInstancePathAnyTypeRead(_client, typeSpecs);
|
||
|
||
try
|
||
{
|
||
var sumResult = await sumCmd.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||
|
||
// ResultSumValues2.ValueResults is a per-item array with Source / Value /
|
||
// ErrorCode. Even when the overall ADS request succeeds, individual sub-items can
|
||
// carry their own ADS error (e.g. SymbolNotFound).
|
||
var output = new (object? value, uint status)[reads.Count];
|
||
var valueResults = sumResult.ValueResults;
|
||
for (var i = 0; i < reads.Count; i++)
|
||
{
|
||
var vr = valueResults[i];
|
||
if (vr.ErrorCode != 0)
|
||
{
|
||
output[i] = (null, TwinCATStatusMapper.MapAdsError((uint)vr.ErrorCode));
|
||
continue;
|
||
}
|
||
var raw = vr.Value;
|
||
output[i] = (PostProcessIecTime(reads[i].Type, raw), TwinCATStatusMapper.Good);
|
||
}
|
||
return output;
|
||
}
|
||
catch (AdsErrorException ex)
|
||
{
|
||
// Whole-batch failure (no symbol-server ack, router unreachable, etc.). Map the
|
||
// overall ADS status onto every entry so callers see uniform status — partial-
|
||
// success marshalling lives in the success branch above.
|
||
var status = TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||
var failed = new (object? value, uint status)[reads.Count];
|
||
for (var i = 0; i < reads.Count; i++) failed[i] = (null, status);
|
||
return failed;
|
||
}
|
||
}
|
||
|
||
public async Task<IReadOnlyList<uint>> WriteValuesAsync(
|
||
IReadOnlyList<TwinCATBulkWriteItem> writes, CancellationToken cancellationToken)
|
||
{
|
||
if (writes.Count == 0) return Array.Empty<uint>();
|
||
|
||
// SumWriteBySymbolPath internally requests symbol handles + issues a single sum-write
|
||
// (IndexGroup 0xF081) carrying all values. One AMS round-trip for N writes.
|
||
var paths = new List<string>(writes.Count);
|
||
var values = new object[writes.Count];
|
||
for (var i = 0; i < writes.Count; i++)
|
||
{
|
||
paths.Add(writes[i].SymbolPath);
|
||
values[i] = ConvertForWrite(writes[i].Type, writes[i].Value);
|
||
}
|
||
|
||
var sumCmd = new SumWriteBySymbolPath(_client, paths);
|
||
|
||
try
|
||
{
|
||
var result = await sumCmd.WriteAsync(values, cancellationToken).ConfigureAwait(false);
|
||
var output = new uint[writes.Count];
|
||
var subErrors = result.SubErrors;
|
||
for (var i = 0; i < writes.Count; i++)
|
||
{
|
||
// SubErrors can be null when the overall request failed before sub-dispatch —
|
||
// surface the OverallError on every slot in that case.
|
||
var code = subErrors is { Length: > 0 } && i < subErrors.Length
|
||
? (uint)subErrors[i]
|
||
: (uint)result.ErrorCode;
|
||
output[i] = TwinCATStatusMapper.MapAdsError(code);
|
||
}
|
||
return output;
|
||
}
|
||
catch (AdsErrorException ex)
|
||
{
|
||
var status = TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||
var failed = new uint[writes.Count];
|
||
for (var i = 0; i < writes.Count; i++) failed[i] = status;
|
||
return failed;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build an <see cref="AnyTypeSpecifier"/> for one bulk-read entry. STRING uses ASCII +
|
||
/// the supplied <paramref name="stringLength"/>; WSTRING uses Unicode (UTF-16). All other
|
||
/// types resolve to a primitive CLR type via <see cref="MapToClrType"/>. IEC time/date
|
||
/// symbols flow as their underlying UDINT (matching the per-tag path in
|
||
/// <see cref="ReadValueAsync"/>) and are post-processed CLR-side after the sum-read.
|
||
/// </summary>
|
||
private static AnyTypeSpecifier BuildAnyTypeSpecifier(TwinCATDataType type, int stringLength) =>
|
||
type switch
|
||
{
|
||
TwinCATDataType.String => new AnyTypeSpecifier(typeof(string), stringLength, Encoding.ASCII),
|
||
TwinCATDataType.WString => new AnyTypeSpecifier(typeof(string), stringLength, Encoding.Unicode),
|
||
_ => new AnyTypeSpecifier(MapToClrType(type)),
|
||
};
|
||
|
||
public void Dispose()
|
||
{
|
||
_client.AdsNotificationEx -= OnAdsNotificationEx;
|
||
|
||
// PR 2.3 — unregister the Symbol-Version listener. Best-effort: by the time we're
|
||
// disposing, the AMS session is already shutting down so the device server may
|
||
// refuse the unregister. Either way, AdsClient.Dispose tears the underlying
|
||
// notification subscription down regardless.
|
||
if (_symbolVersionRegistered)
|
||
{
|
||
try { _client.UnregisterSymbolVersionChanged(OnAdsSymbolVersionChanged); }
|
||
catch { /* best-effort */ }
|
||
_symbolVersionRegistered = false;
|
||
}
|
||
_client.AdsSymbolVersionChanged -= OnAdsSymbolVersionChanged;
|
||
_notifications.Clear();
|
||
|
||
// PR 2.2 — release every cached handle on the wire as a good citizen. Best-effort
|
||
// and bounded to a short window so a hung router doesn't block process shutdown:
|
||
// each delete is fire-and-forget, errors swallowed. The session itself is about to
|
||
// tear down anyway, so the device server will reclaim everything regardless.
|
||
foreach (var kv in _handleCache)
|
||
{
|
||
try
|
||
{
|
||
_ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None);
|
||
}
|
||
catch
|
||
{
|
||
// Per-entry failures are expected on a closing connection.
|
||
}
|
||
}
|
||
_handleCache.Clear();
|
||
|
||
_client.Dispose();
|
||
}
|
||
|
||
/// <summary>
|
||
/// PR 2.2 — flush all process-scoped optional caches (handle cache today). A
|
||
/// proactive Symbol Version invalidation listener arrives in PR 2.3 — until then,
|
||
/// operators / 2.3-aware callers can wipe the cache manually after a known online
|
||
/// change.
|
||
/// </summary>
|
||
public Task FlushOptionalCachesAsync()
|
||
{
|
||
// Best-effort delete on the wire — a held handle won't survive a redeploy anyway,
|
||
// but cleaning up matches the Dispose convention.
|
||
var snapshot = _handleCache.ToArray();
|
||
_handleCache.Clear();
|
||
foreach (var kv in snapshot)
|
||
{
|
||
try
|
||
{
|
||
_ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None);
|
||
}
|
||
catch
|
||
{
|
||
// Best-effort.
|
||
}
|
||
}
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
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
|
||
{
|
||
TwinCATDataType.Bool => typeof(bool),
|
||
TwinCATDataType.SInt => typeof(sbyte),
|
||
TwinCATDataType.USInt => typeof(byte),
|
||
TwinCATDataType.Int => typeof(short),
|
||
TwinCATDataType.UInt => typeof(ushort),
|
||
TwinCATDataType.DInt => typeof(int),
|
||
TwinCATDataType.UDInt => typeof(uint),
|
||
TwinCATDataType.LInt => typeof(long),
|
||
TwinCATDataType.ULInt => typeof(ulong),
|
||
TwinCATDataType.Real => typeof(float),
|
||
TwinCATDataType.LReal => typeof(double),
|
||
TwinCATDataType.String or TwinCATDataType.WString => typeof(string),
|
||
TwinCATDataType.Time or TwinCATDataType.Date
|
||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => typeof(uint),
|
||
_ => typeof(int),
|
||
};
|
||
|
||
internal static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
|
||
{
|
||
TwinCATDataType.Bool => Convert.ToBoolean(value),
|
||
TwinCATDataType.SInt => Convert.ToSByte(value),
|
||
TwinCATDataType.USInt => Convert.ToByte(value),
|
||
TwinCATDataType.Int => Convert.ToInt16(value),
|
||
TwinCATDataType.UInt => Convert.ToUInt16(value),
|
||
TwinCATDataType.DInt => Convert.ToInt32(value),
|
||
TwinCATDataType.UDInt => Convert.ToUInt32(value),
|
||
TwinCATDataType.LInt => Convert.ToInt64(value),
|
||
TwinCATDataType.ULInt => Convert.ToUInt64(value),
|
||
TwinCATDataType.Real => Convert.ToSingle(value),
|
||
TwinCATDataType.LReal => Convert.ToDouble(value),
|
||
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
|
||
// IEC durations (TIME / TOD) accept TimeSpan / Duration-as-Double-ms / raw UDINT.
|
||
// IEC timestamps (DATE / DT) accept DateTime (UTC) / raw UDINT seconds-since-epoch.
|
||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DurationToUDInt(value),
|
||
TwinCATDataType.Date or TwinCATDataType.DateTime => DateTimeToUDInt(value),
|
||
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
|
||
};
|
||
|
||
// IEC 61131-3 epoch is 1970-01-01 UTC for DATE / DT; TIME / TOD are unsigned ms counters.
|
||
private static readonly DateTime IecEpochUtc = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||
|
||
/// <summary>
|
||
/// Convert the raw UDINT wire value for IEC TIME/DATE/DT/TOD into the native CLR type
|
||
/// surfaced upstream — TimeSpan for durations, DateTime (UTC) for timestamps. Other
|
||
/// types pass through unchanged.
|
||
/// </summary>
|
||
internal static object? PostProcessIecTime(TwinCATDataType type, object? value)
|
||
{
|
||
if (value is null) return null;
|
||
var raw = TryGetUInt32(value);
|
||
if (raw is null) return value;
|
||
return type switch
|
||
{
|
||
// TIME / TOD — UDINT milliseconds.
|
||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay
|
||
=> TimeSpan.FromMilliseconds(raw.Value),
|
||
// DT — UDINT seconds since 1970-01-01 UTC.
|
||
TwinCATDataType.DateTime
|
||
=> IecEpochUtc.AddSeconds(raw.Value),
|
||
// DATE — UDINT seconds since 1970-01-01 UTC, but TwinCAT runtimes pin the time
|
||
// component to midnight; pass through the same conversion so we get a date-only
|
||
// value at midnight UTC.
|
||
TwinCATDataType.Date
|
||
=> IecEpochUtc.AddSeconds(raw.Value),
|
||
_ => value,
|
||
};
|
||
}
|
||
|
||
private static uint? TryGetUInt32(object value) => value switch
|
||
{
|
||
uint u => u,
|
||
int i when i >= 0 => (uint)i,
|
||
ushort us => (uint)us,
|
||
short s when s >= 0 => (uint)s,
|
||
long l when l >= 0 && l <= uint.MaxValue => (uint)l,
|
||
ulong ul when ul <= uint.MaxValue => (uint)ul,
|
||
_ => null,
|
||
};
|
||
|
||
private static uint DurationToUDInt(object? value) => value switch
|
||
{
|
||
TimeSpan ts => (uint)Math.Max(0, ts.TotalMilliseconds),
|
||
// OPC UA Duration on the wire is a Double in milliseconds.
|
||
double d => (uint)Math.Max(0, d),
|
||
float f => (uint)Math.Max(0, f),
|
||
_ => Convert.ToUInt32(value),
|
||
};
|
||
|
||
private static uint DateTimeToUDInt(object? value)
|
||
{
|
||
if (value is DateTime dt)
|
||
{
|
||
var utc = dt.Kind == DateTimeKind.Unspecified
|
||
? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
|
||
: dt.ToUniversalTime();
|
||
var seconds = (long)(utc - IecEpochUtc).TotalSeconds;
|
||
if (seconds < 0 || seconds > uint.MaxValue)
|
||
throw new ArgumentOutOfRangeException(nameof(value),
|
||
"DATE/DT value out of UDINT epoch range (1970-01-01..2106-02-07 UTC).");
|
||
return (uint)seconds;
|
||
}
|
||
return Convert.ToUInt32(value);
|
||
}
|
||
|
||
private static bool ExtractBit(object? rawWord, int bit) => rawWord switch
|
||
{
|
||
short s => (s & (1 << bit)) != 0,
|
||
ushort us => (us & (1 << bit)) != 0,
|
||
int i => (i & (1 << bit)) != 0,
|
||
uint ui => (ui & (1u << bit)) != 0,
|
||
long l => (l & (1L << bit)) != 0,
|
||
ulong ul => (ul & (1UL << bit)) != 0,
|
||
_ => false,
|
||
};
|
||
}
|
||
|
||
/// <summary>Default <see cref="ITwinCATClientFactory"/> — one <see cref="AdsTwinCATClient"/> per call.</summary>
|
||
internal sealed class AdsTwinCATClientFactory : ITwinCATClientFactory
|
||
{
|
||
public ITwinCATClient Create() => new AdsTwinCATClient();
|
||
}
|