Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
2026-04-25 22:16:05 -04:00

885 lines
41 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}