using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
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 const int NotificationQueueCapacity = 50_000;
private readonly AdsClient _client = new();
private readonly ConcurrentDictionary _notifications = new();
// Marshals native ADS notifications off the AMS router thread onto a dedicated managed
// task. The router-thread callback (OnAdsNotificationEx) only enqueues; DispatchLoopAsync
// drains and invokes the per-tag OnChange delegates. Blocking the router thread would
// stall every ADS notification across the whole process (docs/v2/driver-specs.md §6).
private readonly Channel _notificationQueue =
Channel.CreateBounded(new BoundedChannelOptions(NotificationQueueCapacity)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = true,
SingleWriter = false,
});
private readonly CancellationTokenSource _dispatchCts = new();
private readonly Task _dispatchLoop;
private int _disposed;
private bool? _stringsAreUtf8;
///
/// Initializes a new instance of the class.
///
public AdsTwinCATClient()
{
_client.AdsNotificationEx += OnAdsNotificationEx;
_dispatchLoop = Task.Run(() => DispatchLoopAsync(_dispatchCts.Token));
}
private readonly record struct PendingNotification(NotificationRegistration Registration, object? Value);
///
public bool IsConnected => _client.IsConnected;
///
/// Occurs when the symbol version changes on the connected ADS target.
///
public event EventHandler? OnSymbolVersionChanged;
/// Raise when is DeviceSymbolVersionInvalid (1809 / 0x0711).
private uint MapAndSignal(uint adsError)
{
if (TwinCATStatusMapper.IsSymbolVersionChanged(adsError))
OnSymbolVersionChanged?.Invoke(this, EventArgs.Empty);
return TwinCATStatusMapper.MapAdsError(adsError);
}
///
public Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
{
if (_client.IsConnected) return Task.CompletedTask;
_client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds);
var netId = AmsNetId.Parse(address.NetId);
_client.Connect(netId, address.Port);
return Task.CompletedTask;
}
///
public async Task<(object? value, uint status)> ReadValueAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int? arrayCount,
CancellationToken cancellationToken)
{
try
{
// Bit-indexed BOOL — TwinCAT's symbol table doesn't expose "WordVar.N" as its
// own symbolic entry (ADS returns DeviceSymbolNotFound), so we read the parent
// container as its widest unsigned primitive and extract the bit locally. The
// .N suffix added by TwinCATSymbolPath.ToAdsSymbolName needs to come back off
// first. uint covers WORD / DWORD containers; BYTE-sized bit containers are
// rare in real code and promoting to uint is harmless for them. Bit-within-word
// is inherently scalar — arrayCount does not apply.
if (bitIndex is int bit && type == TwinCATDataType.Bool)
{
var parent = TwinCATBitAccess.ParentPath(symbolPath);
var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
.ConfigureAwait(false);
if (parentResult.ErrorCode != AdsErrorCode.NoError)
return (null, MapAndSignal((uint)parentResult.ErrorCode));
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
}
// Resolve the PLC symbol first so the declared type can be checked against it. The symbol is
// resolved through the Beckhoff symbol cache (the same lookup ReadValueAsync makes
// internally), so this costs no extra round trip after the first read.
var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) return (null, symbolStatus);
// Array read: raw bytes at the layout TwinCATTypeGuard.CheckArray plans from the RESOLVED
// array type, decoded by TwinCATArrayCodec — the same decode a native array notification
// runs, so poll and notification publish identical arrays. The Beckhoff typed array read this
// replaced failed for most arrays on a live TC 2.11 target: the CLR element type had to match
// exactly (ARRAY OF TIME as uint[] threw), a multi-dimensional array needed a rank-N CLR
// array, and a member array was sliced by its under-reported symbol size.
if (arrayCount is int count)
{
if (TwinCATTypeGuard.CheckArray(type, count, shape, array, out var layout) is not null)
return (null, TwinCATStatusMapper.BadTypeMismatch);
var (arrayBytes, arrayStatus) = await ReadRawAsync(symbolPath, symbol!, layout.ByteLength, cancellationToken)
.ConfigureAwait(false);
return arrayStatus == TwinCATStatusMapper.Good
? (TwinCATArrayCodec.Decode(type, arrayBytes, layout, StringsAreUtf8()), TwinCATStatusMapper.Good)
: (null, arrayStatus);
}
if (TwinCATTypeGuard.CheckRead(type, shape) is not null)
return (null, TwinCATStatusMapper.BadTypeMismatch);
// A STRING / WSTRING is read as its raw bytes at the PLC's own declared width and
// decoded here: it ends at the first NUL, and a STRING is Windows-1252 rather than the
// host culture's ANSI code page the Beckhoff marshaller would fall back to. The width is
// the RESOLVED type's (shape.ByteSize) and the read is addressed explicitly: asking the
// library for typeof(byte[]) slices by the symbol's own ByteSize, which a TwinCAT 2
// member symbol under-reports (STRING(80) → 11 B, live on a TC 2.11 CX).
if (type is TwinCATDataType.String or TwinCATDataType.WString)
{
var (bytes, readStatus) = await ReadRawAsync(symbolPath, symbol!, shape.ByteSize, cancellationToken)
.ConfigureAwait(false);
return readStatus == TwinCATStatusMapper.Good
? (DecodeString(type, bytes, StringsAreUtf8()), TwinCATStatusMapper.Good)
: (null, readStatus);
}
var clrType = MapToClrType(type);
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
.ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
return (null, MapAndSignal((uint)result.ErrorCode));
return (TwinCATTemporalCodec.IsTemporal(type) && result.Value is uint rawTemporal
? TwinCATTemporalCodec.Decode(type, rawTemporal)
: result.Value,
TwinCATStatusMapper.Good);
}
catch (AdsErrorException ex)
{
return (null, MapAndSignal((uint)ex.ErrorCode));
}
}
///
public async Task> ReadValuesAsync(
IReadOnlyList reads, CancellationToken cancellationToken)
{
var results = new (object? value, uint status)[reads.Count];
var planned = new List(reads.Count);
Task<(object? value, uint status)> ReadSingly(int index) =>
ReadValueAsync(reads[index].SymbolPath, reads[index].Type, reads[index].BitIndex,
reads[index].ArrayCount, cancellationToken);
// Plan every item exactly as the single read would (symbol resolved from the Beckhoff symbol cache,
// declared type checked against it), but stop short of the wire read: eligible items become sum
// commands. Anything the sum command cannot carry exactly is read on its own right here.
for (var i = 0; i < reads.Count; i++)
{
var r = reads[i];
try
{
if (r.BitIndex is not null && r.Type == TwinCATDataType.Bool)
{
results[i] = await ReadSingly(i).ConfigureAwait(false);
continue;
}
var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(r.SymbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) { results[i] = (null, symbolStatus); continue; }
TwinCATArrayLayout layout;
if (r.ArrayCount is int count)
{
// Arrays are raw bytes on the single path too, with the same strict element rule.
if (TwinCATTypeGuard.CheckArray(r.Type, count, shape, array, out layout) is not null)
{
results[i] = (null, TwinCATStatusMapper.BadTypeMismatch);
continue;
}
}
else
{
if (TwinCATTypeGuard.CheckRead(r.Type, shape) is not null)
{
results[i] = (null, TwinCATStatusMapper.BadTypeMismatch);
continue;
}
// The single read value-converts a numeric pair (DInt over a PLC INT is fine there); raw
// bytes decoded as the declared type would not. Only an exact width + kind match rides the
// sum command — the rest keeps the Beckhoff conversion by reading singly.
if (!TwinCATSumReadPlanner.ScalarEligible(r.Type, shape))
{
results[i] = await ReadSingly(i).ConfigureAwait(false);
continue;
}
// A scalar is a one-element "array" at the type's own width (a STRING at the PLC's declared
// width), so scalar and array share one raw decode.
layout = new TwinCATArrayLayout(1, r.Type is TwinCATDataType.String or TwinCATDataType.WString
? shape.ByteSize
: TwinCATTypeGuard.DeclaredByteSize(r.Type));
}
// A handle-addressed symbol (0xF005) needs a fresh variable handle per read — not expressible
// as an index-group/offset sum entry. Read it individually.
if (symbol!.IndexGroup == SymbolValueByHandle)
{
results[i] = await ReadSingly(i).ConfigureAwait(false);
continue;
}
planned.Add(new PlannedSumRead(i, symbol.IndexGroup, symbol.IndexOffset, layout, r.ArrayCount is not null));
}
catch (AdsErrorException ex)
{
results[i] = (null, MapAndSignal((uint)ex.ErrorCode));
}
}
if (planned.Count == 0) return results;
// Split to the byte budget; a lone item (or an oversized one) is just a read.
var groups = TwinCATSumReadPlanner.Partition(
[.. planned.Select(p => p.Layout.ByteLength)], TwinCATSumReadPlanner.MaxBatchBytes);
foreach (var group in groups)
{
var items = group.Select(g => planned[g]).ToList();
if (items.Count == 1)
{
results[items[0].Index] = await ReadSingly(items[0].Index).ConfigureAwait(false);
continue;
}
await ReadGroupAsync(items, reads, results, ReadSingly, cancellationToken).ConfigureAwait(false);
}
return results;
}
private async Task ReadGroupAsync(
List items, IReadOnlyList reads,
(object? value, uint status)[] results,
Func> readSingly, CancellationToken cancellationToken)
{
async Task FallBackAsync()
{
foreach (var p in items) results[p.Index] = await readSingly(p.Index).ConfigureAwait(false);
}
ResultSumReadRaw sum;
try
{
sum = await CreateSumRead(_client, items.Select(p => (p.IndexGroup, p.IndexOffset, p.Layout.ByteLength)))
.ReadRawAsync(cancellationToken).ConfigureAwait(false);
}
catch (AdsErrorException ex) when (TwinCATSumReadPlanner.IsRefusal((uint)ex.ErrorCode))
{
throw new TwinCATSumReadRefusedException((uint)ex.ErrorCode, ex);
}
catch (ArgumentException)
{
// The library slices the response by the REQUESTED lengths and throws when a target answers with
// less. That is this target mangling this response, not a comms fault: read the group singly.
NoteSumFallback(0);
await FallBackAsync().ConfigureAwait(false);
return;
}
// On an overall failure the library RETURNS the code (SubErrors all set, ReadBlocks null).
if (sum.ErrorCode != AdsErrorCode.NoError)
{
var overall = (uint)sum.ErrorCode;
if (TwinCATSumReadPlanner.IsRefusal(overall))
throw new TwinCATSumReadRefusedException(overall);
if (TwinCATSumReadPlanner.IsRequestRejected(overall))
{
NoteSumFallback(overall);
await FallBackAsync().ConfigureAwait(false); // too big / bad size for THIS request: no latch
return;
}
var mapped = MapAndSignal(overall);
foreach (var p in items) results[p.Index] = (null, mapped);
return;
}
var utf8 = StringsAreUtf8();
for (var k = 0; k < items.Count; k++)
{
var p = items[k];
var subError = sum.SubErrors is { } errs && k < errs.Length ? errs[k] : AdsErrorCode.NoError;
ReadOnlyMemory? block = sum.ReadBlocks is { } blocks && k < blocks.Count ? blocks[k] : null;
results[p.Index] = TwinCATSumReadPlanner.DecodeItem(
reads[p.Index].Type, subError, block, p.Layout, p.IsArray, utf8, MapAndSignal);
}
}
///
/// Builds the index-group/offset ADS sum-read command for .
///
///
/// The entities MUST be handed over as an ARRAY. Beckhoff's
/// SumRead(IAdsConnection, IList<IgIoSumReadEntity>, ...) overload casts its argument to
/// IList<SumDataEntity> internally; that cast succeeds for an array (array covariance) and
/// throws for a List<IgIoSumReadEntity>. The live gate
/// (hw2, 2026-10-08, TC3 Z35474) found every sum-read chunk failing that way — the driver's catch-all
/// turned it into BadCommunicationError for every batched tag on every poll — while the fake-client
/// unit tests never constructed a real . The constructor does no I/O, so
/// TwinCATSumReadConstructionTests pins this without a PLC.
///
/// The ADS connection the command will run on.
/// Index group, index offset and byte length per item, in request order.
/// A ready-to-run sum-read command (relaxed error strategy).
internal static SumRead CreateSumRead(
IAdsConnection connection, IEnumerable<(uint IndexGroup, uint IndexOffset, int ByteLength)> entries)
{
IgIoSumReadEntity[] entities = [.. entries.Select(e => new IgIoSumReadEntity(e.IndexGroup, e.IndexOffset, e.ByteLength))];
return new SumRead(connection, entities, SumCommandErrorStrategy.Relaxed);
}
private const uint SymbolValueByHandle = 0xF005;
private readonly record struct PlannedSumRead(int Index, uint IndexGroup, uint IndexOffset, TwinCATArrayLayout Layout, bool IsArray);
private string? _lastAdsState;
private long _sumFallbackCount;
private long _lastSumFallbackCode;
private void NoteSumFallback(uint code)
{
Interlocked.Exchange(ref _lastSumFallbackCode, code);
Interlocked.Increment(ref _sumFallbackCount);
}
///
public long SumFallbackCount => Interlocked.Read(ref _sumFallbackCount);
///
public uint LastSumFallbackCode => (uint)Interlocked.Read(ref _lastSumFallbackCode);
///
public string? LastAdsState => Volatile.Read(ref _lastAdsState);
///
/// Resolves the PLC symbol behind into the facts
/// compares a declared type against. Uses the Beckhoff symbol
/// cache (lookup on), so repeat calls are local.
///
private async Task<(TwinCATPlcSymbolShape shape, TwinCATPlcArrayShape? array, IAdsSymbol? symbol, uint status)>
DescribeSymbolAsync(string symbolPath, CancellationToken cancellationToken)
{
var result = await _client.ReadSymbolAsync(symbolPath, cancellationToken).ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
return (default, null, null, MapAndSignal((uint)result.ErrorCode));
if (result.Value is not { } symbol)
return (default, null, null, TwinCATStatusMapper.BadNodeIdUnknown);
// Resolve aliases (T_MaxString → STRING(255)) so the guard sees the base type, and take the
// resolved type's byte size — a TwinCAT 2 member symbol's own ByteSize is under-reported.
var dataType = symbol.DataType?.Resolve();
var shape = TwinCATPlcSymbolShape.Classify(
dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize, dataType?.ByteSize, symbol.TypeName);
return (shape, DescribeArray(dataType), symbol, TwinCATStatusMapper.Good);
}
///
/// The array facts plans from: every dimension's element
/// count, the resolved array size, and the alias-resolved element type. Null for a non-array.
///
private static TwinCATPlcArrayShape? DescribeArray(IDataType? dataType)
{
if (dataType is not IArrayType arrayType || arrayType.Dimensions is not { } dims) return null;
var element = arrayType.ElementType?.Resolve();
var elementShape = TwinCATPlcSymbolShape.Classify(
element?.Name ?? arrayType.ElementTypeName, element?.Category, element?.ByteSize ?? 0);
return new TwinCATPlcArrayShape([.. dims.Select(d => d.ElementCount)], dataType.ByteSize, elementShape);
}
///
/// Reads exactly raw bytes of a resolved symbol: by its
/// index group/offset, or — for a handle-addressed symbol (0xF005, whose offset is a handle
/// that may be stale) — through a fresh variable handle.
///
private async Task<(byte[] bytes, uint status)> ReadRawAsync(
string symbolPath, IAdsSymbol symbol, int length, CancellationToken cancellationToken)
{
const uint SymbolValueByHandle = 0xF005;
if (symbol.IndexGroup != SymbolValueByHandle)
{
var direct = await _client.ReadAsync(symbol.IndexGroup, symbol.IndexOffset, length, cancellationToken)
.ConfigureAwait(false);
return direct.ErrorCode == AdsErrorCode.NoError
? (direct.Data.ToArray(), TwinCATStatusMapper.Good)
: ([], MapAndSignal((uint)direct.ErrorCode));
}
var handle = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
if (handle.ErrorCode != AdsErrorCode.NoError) return ([], MapAndSignal((uint)handle.ErrorCode));
try
{
var read = await _client.ReadAsync(handle.Handle, length, cancellationToken).ConfigureAwait(false);
return read.ErrorCode == AdsErrorCode.NoError
? (read.Data.ToArray(), TwinCATStatusMapper.Good)
: ([], MapAndSignal((uint)read.ErrorCode));
}
finally
{
try { await _client.DeleteVariableHandleAsync(handle.Handle, CancellationToken.None).ConfigureAwait(false); }
catch { /* best-effort release */ }
}
}
///
/// True when the target reports UTF-8 STRING data (TwinCAT 3.1 4026+ "UTF8 Encoding for
/// STRING"); otherwise a STRING is Windows-1252. Read once per client from the symbol upload
/// info the Beckhoff client already fetches.
///
private bool StringsAreUtf8()
{
if (_stringsAreUtf8 is bool known) return known;
try
{
var utf8 = _client.DefaultValueEncoding.CodePage == System.Text.Encoding.UTF8.CodePage;
_stringsAreUtf8 = utf8;
return utf8;
}
catch
{
return false; // not determinable yet (e.g. not connected) — Windows-1252, retry next time
}
}
private static string DecodeString(TwinCATDataType type, byte[] bytes, bool utf8) =>
type == TwinCATDataType.WString
? TwinCATStringCodec.DecodeWString(bytes)
: TwinCATStringCodec.DecodeString(bytes, utf8);
///
public async Task WriteValueAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
object? value,
CancellationToken cancellationToken)
{
try
{
var converted = ConvertForWrite(type, value);
var result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken)
.ConfigureAwait(false);
return result.ErrorCode == AdsErrorCode.NoError
? TwinCATStatusMapper.Good
: MapAndSignal((uint)result.ErrorCode);
}
catch (AdsErrorException ex)
{
return MapAndSignal((uint)ex.ErrorCode);
}
}
///
public async Task ProbeAsync(CancellationToken cancellationToken)
{
try
{
var state = await _client.ReadStateAsync(cancellationToken).ConfigureAwait(false);
var ok = state.ErrorCode == AdsErrorCode.NoError;
Volatile.Write(ref _lastAdsState, ok ? state.State.AdsState.ToString() : null);
return ok;
}
catch
{
return false;
}
}
///
public async Task AddNotificationAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int? arrayCount,
TimeSpan cycleTime,
int maxDelayMs,
Action onChange,
CancellationToken cancellationToken)
{
var clrType = MapToClrType(type);
int[]? anyTypeArgs = null;
var stringsUtf8 = false;
TwinCATArrayLayout? arrayLayout = null;
var registerPath = symbolPath;
if (bitIndex is int bit && type == TwinCATDataType.Bool)
{
// ADS has no symbol for WORD.N (live on TC 2.11: DeviceSymbolNotFound for .ADSSTATE_RUN.0,
// which took the whole subscribe call down). Register the PARENT word as raw bytes at its
// resolved width and read the bit in OnAdsNotificationEx — the read path's model.
registerPath = TwinCATBitAccess.ParentPath(symbolPath);
var (parent, _, _, parentStatus) = await DescribeSymbolAsync(registerPath, cancellationToken)
.ConfigureAwait(false);
if (parentStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException(
$"Resolving {registerPath} for a notification failed with status 0x{parentStatus:X8}");
if (TwinCATBitAccess.CheckParent(bit, parent) is { } bitReason)
throw new TwinCATTypeMismatchException(symbolPath, bitReason);
clrType = typeof(byte[]);
anyTypeArgs = [parent.ByteSize];
}
else
{
// A notification carries raw bytes decoded as the DECLARED type, so a declared type that
// cannot decode the PLC symbol (width, float/integer or string kind differ) would publish
// wrong values at Good. Refuse it here; the driver isolates the one tag to Bad.
var (shape, array, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException(
$"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}");
if (arrayCount is int count)
{
// An array registers the whole planned layout (element stride × authored length, from the
// RESOLVED array type) as raw bytes and decodes it in OnAdsNotificationEx with the same
// TwinCATArrayCodec a poll read uses — so both paths publish the identical array.
if (TwinCATTypeGuard.CheckArray(type, count, shape, array, out var layout) is { } arrayReason)
throw new TwinCATTypeMismatchException(symbolPath, arrayReason);
if (TwinCATTypeGuard.CheckNotificationSize(layout) is { } sizeReason)
throw new TwinCATNotificationRefusedException(symbolPath, sizeReason, TwinCATStatusMapper.BadOutOfRange);
clrType = typeof(byte[]);
anyTypeArgs = [layout.ByteLength];
stringsUtf8 = StringsAreUtf8();
arrayLayout = layout;
}
else if (TwinCATTypeGuard.CheckNotification(type, shape) is { } reason)
{
throw new TwinCATTypeMismatchException(symbolPath, reason);
}
// A string has no fixed width: the "any" type string needs an explicit length (the
// Beckhoff marshaller throws ArgumentNullException without one), and a STRING(n) is n+1
// bytes with n chosen per declaration. Register the PLC's own byte width as raw bytes
// and decode them in OnAdsNotificationEx with the same rules as a read.
if (arrayLayout is null && type is TwinCATDataType.String or TwinCATDataType.WString)
{
clrType = typeof(byte[]);
anyTypeArgs = [shape.ByteSize];
stringsUtf8 = StringsAreUtf8();
}
}
var cycleMs = (int)Math.Max(1, cycleTime.TotalMilliseconds);
var settings = new NotificationSettings(AdsTransMode.OnChange, cycleMs, Math.Max(0, maxDelayMs));
// AddDeviceNotificationExAsync returns Task; AdsNotificationEx fires
// with the handle as part of the event args so we use the handle as the correlation
// key into _notifications.
var result = await _client.AddDeviceNotificationExAsync(
registerPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
.ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
throw new InvalidOperationException(
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {registerPath}");
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
{
StringsUtf8 = stringsUtf8,
ArrayLayout = arrayLayout,
};
_notifications[result.Handle] = reg;
return reg;
}
///
/// Runs on the AMS router thread. Does the cheap decode (bit
/// extraction, string, array) then enqueues — no driver logic, no consumer callbacks — so a slow consumer
/// can never stall ADS notification delivery for the rest of the process.
/// Drops the notification (DropWrite) if the queue is saturated.
///
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)
{
if (value is not byte[] word || bit / 8 >= word.Length) return;
value = TwinCATBitAccess.ReadBit(word, bit);
}
else if (reg.ArrayLayout is { } layout && value is byte[] arrayBytes)
{
try { value = TwinCATArrayCodec.Decode(reg.Type, arrayBytes, layout, reg.StringsUtf8); }
catch (ArgumentException) { return; } // a short buffer is never published as a short array
}
else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString)
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
else if (TwinCATTemporalCodec.IsTemporal(reg.Type) && value is uint rawTemporal)
value = TwinCATTemporalCodec.Decode(reg.Type, rawTemporal);
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
}
private async Task DispatchLoopAsync(CancellationToken ct)
{
try
{
await foreach (var pending in _notificationQueue.Reader.ReadAllAsync(ct).ConfigureAwait(false))
{
try { pending.Registration.OnChange(pending.Registration.SymbolPath, pending.Value); }
catch { /* consumer-side errors stay on this managed task, not the ADS thread */ }
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Clean shutdown.
}
}
///
/// Deletes a notification subscription by handle.
///
/// The notification handle to delete.
/// The cancellation token.
/// A task representing the asynchronous delete operation.
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 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.
//
// Flat is VERIFIED on TC3 (hw2 live gate 2026-10-08, Z35474, TwinCAT 3.1.4024): every struct
// root carried SubSymbols, 10,770 leaves; VirtualTree gave 1,459, a strict subset that drops
// array-of-struct element members. TC2 Flat was proven 2026-10-07. Do not switch to VirtualTree.
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
// Expand struct/UDT/FB-instance symbols down to their addressable atomic members via the
// pure TwinCATSymbolExpander (unit-proven; the real ISymbol surface is non-injectable so the
// recursion is tested through the ITwinCATSymbolNode abstraction, then adapted here). The
// expander dedups by InstancePath, so it also neutralizes any Flat-vs-tree double-listing.
foreach (var ds in TwinCATSymbolExpander.ExpandLeaves(
loader.Symbols.Select(s => new AdsSymbolNode(s))))
{
cancellationToken.ThrowIfCancellationRequested();
yield return ds;
}
}
///
/// Adapts Beckhoff's onto so the pure
/// can walk it. Children are materialized eagerly per node
/// (the loader has already downloaded the whole symbol-info blob, so descending sub-symbols is
/// a local operation). Reuses the existing +
/// so atomic-type mapping and access-rights handling stay identical to the pre-expansion path.
///
private sealed class AdsSymbolNode : ITwinCATSymbolNode
{
// Computed once in the ctor so repeated property access during recursive expansion
// doesn't re-materialize Children or re-invoke MapSymbolType / IsSymbolWritable.
private readonly ISymbol _symbol;
private readonly bool _isStruct;
private readonly (TwinCATDataType? Type, int? ArrayLength) _mapped;
private readonly IReadOnlyList _children;
private readonly bool _readOnly;
///
/// Initializes a new instance of the class, computing its
/// struct/mapped-type/children/read-only state eagerly from the wrapped .
///
/// The Beckhoff ADS symbol to adapt.
public AdsSymbolNode(ISymbol symbol)
{
_symbol = symbol;
_isStruct = symbol.DataType?.Category == DataTypeCategory.Struct;
_mapped = MapSymbolType(symbol.DataType);
_children = symbol.SubSymbols is { } subs
? subs.Select(s => (ITwinCATSymbolNode)new AdsSymbolNode(s)).ToList()
: [];
_readOnly = !IsSymbolWritable(symbol);
}
///
public string InstancePath => _symbol.InstancePath;
///
public bool IsStruct => _isStruct;
///
public (TwinCATDataType? Type, int? ArrayLength) Mapped => _mapped;
///
public IReadOnlyList Children => _children;
///
public bool ReadOnly => _readOnly;
}
///
/// Resolves a symbol's to the driver's atomic
/// plus an optional 1-D array length.
///
/// A 1-D array symbol (Category == Array, count 1)
/// surfaces as its ELEMENT type with the dimension's as
/// the array length. ASSUMPTION (no live ADS here): the array exposes
/// with + a
/// collection whose single carries
/// — matches the TwinCAT TypeSystem surface
/// (TwinCAT.TypeSystem.IArrayType / IDimensionCollection.GetDimensionLengths()).
/// Verified by unit test against the fake client only.
///
/// Multi-dimensional arrays (Dimensions.Count > 1, including jagged) are NOT in scope for
/// Phase 4c — they fall through as a scalar/unsupported null (which DiscoverAsync drops),
/// never silently mis-reported as a 1-D array.
///
private static (TwinCATDataType? type, int? arrayLength) MapSymbolType(IDataType? dataType)
{
if (dataType is null) return (null, null);
if (dataType.Category == DataTypeCategory.Array && dataType is IArrayType arr)
{
// 1-D only. A multi-dim / jagged array is out of atomic scope → drop (null).
var dims = arr.Dimensions;
if (arr.IsJagged || dims is null || dims.Count != 1) return (null, null);
var element = MapSymbolTypeName(arr.ElementType?.Name);
if (element is null) return (null, null); // element type is itself unsupported (UDT array etc.)
var length = dims[0].ElementCount;
return length > 0 ? (element, length) : (null, null);
}
return (MapSymbolTypeName(dataType.Name), null);
}
internal static TwinCATDataType? MapSymbolTypeName(string? typeName)
{
if (typeName is null) return null;
// A TwinCAT 2 data-type upload pads some member type names with a trailing blank
// ("REAL ", "DINT " on a TC 2.11 CX) — trim before matching.
var name = typeName.Trim();
// SymbolLoader emits STRING(80) / WSTRING(80) with the declared bound baked into
// the type name — strip the "(...)" suffix so sized strings map onto the bare
// String/WString atom the driver speaks. Only for the string kinds: any other
// parenthesised form ("INT (VAR_IN_OUT)" — a 4-byte reference on TC2) is not the
// scalar its prefix names and stays unsupported.
var paren = name.IndexOf('(');
var bare = paren > 0 ? name[..paren] : name;
if (paren > 0 && bare is not ("STRING" or "WSTRING")) return null;
return bare switch
{
"BOOL" or "BIT" => TwinCATDataType.Bool,
"SINT" or "BYTE" => TwinCATDataType.SInt,
// TwinCAT 2 reports top-level globals by their ADS base-type name (INT16 / UINT16 / …),
// and the Beckhoff type system keeps that spelling as a built-in primitive
// (AdsSymbolProvider.CreateBuildInTypes) — map each like its IEC twin.
"USINT" or "UINT8" => TwinCATDataType.USInt,
"INT" or "WORD" or "INT16" => TwinCATDataType.Int,
"UINT" or "UINT16" => TwinCATDataType.UInt,
"DINT" or "DWORD" or "INT32" => TwinCATDataType.DInt,
"UDINT" or "UINT32" => TwinCATDataType.UDInt,
"LINT" or "LWORD" => TwinCATDataType.LInt,
"ULINT" => TwinCATDataType.ULInt,
"REAL" or "FLOAT" => TwinCATDataType.Real,
"LREAL" or "DOUBLE" => 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;
}
///
/// Disposes the client and releases all resources.
///
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_client.AdsNotificationEx -= OnAdsNotificationEx;
_notifications.Clear();
// Stop the dispatch loop: complete the queue so the reader drains + exits, then
// cancel as a backstop. Best-effort wait so a wedged consumer can't hang teardown.
_notificationQueue.Writer.TryComplete();
_dispatchCts.Cancel();
try { _dispatchLoop.Wait(TimeSpan.FromSeconds(2)); } catch { /* shutdown */ }
_dispatchCts.Dispose();
_client.Dispose();
}
private sealed class NotificationRegistration(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
Action onChange,
AdsTwinCATClient owner,
uint handle) : ITwinCATNotificationHandle
{
///
/// Gets the symbol path being monitored.
///
public string SymbolPath { get; } = symbolPath;
///
/// Gets the TwinCAT data type of the symbol.
///
public TwinCATDataType Type { get; } = type;
///
/// Gets the optional bit index for BOOL values.
///
public int? BitIndex { get; } = bitIndex;
///
/// Gets the callback to invoke when the value changes.
///
public Action OnChange { get; } = onChange;
///
/// Gets a value indicating whether a STRING notification decodes as UTF-8 (else Windows-1252).
///
public bool StringsUtf8 { get; init; }
///
/// Gets the raw-bytes layout of an array notification, decoded by ; null for a scalar.
///
public TwinCATArrayLayout? ArrayLayout { get; init; }
///
/// Disposes the notification subscription.
///
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,
TwinCATDataType.Time or TwinCATDataType.Date
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => TwinCATTemporalCodec.Encode(type, value),
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
};
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,
};
}
/// Default — one per call.
internal sealed class AdsTwinCATClientFactory : ITwinCATClientFactory
{
///
public ITwinCATClient Create() => new AdsTwinCATClient();
}