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;
///
/// Default backed by Beckhoff's .
/// One instance per AMS target; reused across reads / writes / probes.
///
///
/// Wire behavior depends on a reachable AMS router — on Windows the router comes
/// from TwinCAT XAR; elsewhere from the Beckhoff.TwinCAT.Ads.TcpRouter package
/// hosted by the server process. Neither is built-in here; deployment wires one in.
///
/// Error mapping — ADS error codes surface through
/// and get translated to OPC UA status codes via .
///
internal sealed class AdsTwinCATClient : ITwinCATClient
{
private readonly AdsClient _client = new();
private readonly ConcurrentDictionary _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 _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 _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 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
// + 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;
/// Test-only — current size of the handle cache.
internal int HandleCacheCount => _handleCache.Count;
/// Test-only — total Symbol-Version bumps observed since process start.
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);
}
///
/// PR 2.3 — register the Beckhoff AdsSymbolVersionChanged event listener
/// against the current AMS session. Idempotent: a second call while
/// is true 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.
///
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.
}
}
///
/// 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 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.
///
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));
}
}
///
/// Resolve to a cached ADS variable handle (or create one
/// on first use) and dispatch a .
/// On evicts the cached handle
/// + retries once with a freshly-created handle — covers the online-change race where
/// the symbol survives but its descriptor moves.
///
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);
}
///
/// Mirror of for writes. Returns the final
/// ; the caller maps that to an OPC UA status.
///
private async Task 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;
}
///
/// Lookup-or-create the cached ADS handle for . The
/// guarantees publication safety,
/// but two concurrent callers on a cold key may both call
/// .
/// 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.
///
internal async ValueTask 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);
}
///
/// 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.
///
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);
/// Apply per-element IEC TIME/DATE post-processing to a flat array result.
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