Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
T
Joseph Doherty 664ff75ee2 fix(twincat): #645 live gate — sum-read never worked on hardware; TC2 diagnostics fallback
Read-only live gate on Elma presses Z35474 (TC3 3.1.4024) and Z34834 (TC2 2.11):

- Sum-read: every chunk came back BadCommunicationError. Beckhoff's
  SumRead(IList<IgIoSumReadEntity>) ctor casts to IList<SumDataEntity>, which
  throws InvalidCastException for a List<> (only an array survives via
  covariance). The fake-client tests never built a real SumRead. New
  AdsTwinCATClient.CreateSumRead passes an array; TwinCATSumReadConstructionTests
  pins it (red on the List form) plus a falsifiability control on the library.
  After the fix: 59 tags, status identical everywhere, values identical on every
  non-moving tag, ~0.35-0.66 s vs ~2.7 s per poll; TC2 CE 2.11 answers sum reads.
- An unexpected sum chunk failure now logs one Warning per device (it was
  invisible: the health message was overwritten by the next good read).
- Diagnostics: TC2 has no TwinCAT_SystemInfoVarList; fall back to .SYSTEMINFO /
  .SYSTEMTASKINFOARR[1] once the TC3 name answers not-found (live: all six keys).
- #612 browse: Flat verified on TC3 (10,770 leaves; VirtualTree 1,459 strict
  subset) — comment and docs updated, no load-mode option needed.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 21:17:44 -04:00

938 lines
45 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.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;
/// <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 const int NotificationQueueCapacity = 50_000;
private readonly AdsClient _client = new();
private readonly ConcurrentDictionary<uint, NotificationRegistration> _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<PendingNotification> _notificationQueue =
Channel.CreateBounded<PendingNotification>(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;
/// <summary>
/// Initializes a new instance of the <see cref="AdsTwinCATClient"/> class.
/// </summary>
public AdsTwinCATClient()
{
_client.AdsNotificationEx += OnAdsNotificationEx;
_dispatchLoop = Task.Run(() => DispatchLoopAsync(_dispatchCts.Token));
}
private readonly record struct PendingNotification(NotificationRegistration Registration, object? Value);
/// <inheritdoc />
public bool IsConnected => _client.IsConnected;
/// <summary>
/// Occurs when the symbol version changes on the connected ADS target.
/// </summary>
public event EventHandler? OnSymbolVersionChanged;
/// <summary>Raise <see cref="OnSymbolVersionChanged"/> when <paramref name="adsError"/> is <c>DeviceSymbolVersionInvalid</c> (1809 / 0x0711).</summary>
private uint MapAndSignal(uint adsError)
{
if (TwinCATStatusMapper.IsSymbolVersionChanged(adsError))
OnSymbolVersionChanged?.Invoke(this, EventArgs.Empty);
return TwinCATStatusMapper.MapAdsError(adsError);
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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));
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
{
var results = new (object? value, uint status)[reads.Count];
var planned = new List<PlannedSumRead>(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<PlannedSumRead> items, IReadOnlyList<TwinCATBulkReadItem> reads,
(object? value, uint status)[] results,
Func<int, Task<(object? value, uint status)>> 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<byte>? 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);
}
}
/// <summary>
/// Builds the index-group/offset ADS sum-read command for <paramref name="entries"/>.
/// </summary>
/// <remarks>
/// The entities MUST be handed over as an ARRAY. Beckhoff's
/// <c>SumRead(IAdsConnection, IList&lt;IgIoSumReadEntity&gt;, ...)</c> overload casts its argument to
/// <c>IList&lt;SumDataEntity&gt;</c> internally; that cast succeeds for an array (array covariance) and
/// throws <see cref="InvalidCastException"/> for a <c>List&lt;IgIoSumReadEntity&gt;</c>. 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 <c>BadCommunicationError</c> for every batched tag on every poll — while the fake-client
/// unit tests never constructed a real <see cref="SumRead"/>. The constructor does no I/O, so
/// <c>TwinCATSumReadConstructionTests</c> pins this without a PLC.
/// </remarks>
/// <param name="connection">The ADS connection the command will run on.</param>
/// <param name="entries">Index group, index offset and byte length per item, in request order.</param>
/// <returns>A ready-to-run sum-read command (relaxed error strategy).</returns>
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);
}
/// <inheritdoc />
public long SumFallbackCount => Interlocked.Read(ref _sumFallbackCount);
/// <inheritdoc />
public uint LastSumFallbackCode => (uint)Interlocked.Read(ref _lastSumFallbackCode);
/// <inheritdoc />
public string? LastAdsState => Volatile.Read(ref _lastAdsState);
/// <summary>
/// Resolves the PLC symbol behind <paramref name="symbolPath"/> into the facts
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
/// cache (lookup on), so repeat calls are local.
/// </summary>
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);
}
/// <summary>
/// The array facts <see cref="TwinCATTypeGuard.CheckArray"/> plans from: every dimension's element
/// count, the resolved array size, and the alias-resolved element type. Null for a non-array.
/// </summary>
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);
}
/// <summary>
/// Reads exactly <paramref name="length"/> 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.
/// </summary>
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 */ }
}
}
/// <summary>
/// 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.
/// </summary>
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);
/// <inheritdoc />
public async Task<uint> 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);
}
}
/// <inheritdoc />
public async Task<bool> 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;
}
}
/// <inheritdoc />
public async Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int? arrayCount,
TimeSpan cycleTime,
int maxDelayMs,
Action<string, object?> 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<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(
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;
}
/// <summary>
/// Runs on the <see cref="AdsClient"/> 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.
/// </summary>
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.
}
}
/// <summary>
/// Deletes a notification subscription by handle.
/// </summary>
/// <param name="handle">The notification handle to delete.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the asynchronous delete operation.</returns>
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 */ }
}
/// <inheritdoc />
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.
//
// 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;
}
}
/// <summary>
/// Adapts Beckhoff's <see cref="ISymbol"/> onto <see cref="ITwinCATSymbolNode"/> so the pure
/// <see cref="TwinCATSymbolExpander"/> 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 <see cref="MapSymbolType"/> + <see cref="IsSymbolWritable"/>
/// so atomic-type mapping and access-rights handling stay identical to the pre-expansion path.
/// </summary>
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<ITwinCATSymbolNode> _children;
private readonly bool _readOnly;
/// <summary>
/// Initializes a new instance of the <see cref="AdsSymbolNode"/> class, computing its
/// struct/mapped-type/children/read-only state eagerly from the wrapped <paramref name="symbol"/>.
/// </summary>
/// <param name="symbol">The Beckhoff ADS symbol to adapt.</param>
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);
}
/// <inheritdoc />
public string InstancePath => _symbol.InstancePath;
/// <inheritdoc />
public bool IsStruct => _isStruct;
/// <inheritdoc />
public (TwinCATDataType? Type, int? ArrayLength) Mapped => _mapped;
/// <inheritdoc />
public IReadOnlyList<ITwinCATSymbolNode> Children => _children;
/// <inheritdoc />
public bool ReadOnly => _readOnly;
}
/// <summary>
/// Resolves a symbol's <see cref="IDataType"/> to the driver's atomic
/// <see cref="TwinCATDataType"/> plus an optional 1-D array length.
/// <para>
/// A 1-D array symbol (<c>Category == Array</c>, <see cref="IArrayType.Dimensions"/> count 1)
/// surfaces as its ELEMENT type with the dimension's <see cref="IDimension.ElementCount"/> as
/// the array length. ASSUMPTION (no live ADS here): the array <see cref="IDataType"/> exposes
/// <see cref="IArrayType"/> with <see cref="IArrayType.ElementType"/> + a
/// <see cref="IArrayType.Dimensions"/> collection whose single <see cref="IDimension"/> carries
/// <see cref="IDimension.ElementCount"/> — matches the TwinCAT TypeSystem surface
/// (<c>TwinCAT.TypeSystem.IArrayType</c> / <c>IDimensionCollection.GetDimensionLengths()</c>).
/// Verified by unit test against the fake client only.
/// </para>
/// Multi-dimensional arrays (Dimensions.Count > 1, including jagged) are NOT in scope for
/// Phase 4c — they fall through as a scalar/unsupported <c>null</c> (which DiscoverAsync drops),
/// never silently mis-reported as a 1-D array.
/// </summary>
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;
}
/// <summary>
/// Disposes the client and releases all resources.
/// </summary>
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<string, object?> onChange,
AdsTwinCATClient owner,
uint handle) : ITwinCATNotificationHandle
{
/// <summary>
/// Gets the symbol path being monitored.
/// </summary>
public string SymbolPath { get; } = symbolPath;
/// <summary>
/// Gets the TwinCAT data type of the symbol.
/// </summary>
public TwinCATDataType Type { get; } = type;
/// <summary>
/// Gets the optional bit index for BOOL values.
/// </summary>
public int? BitIndex { get; } = bitIndex;
/// <summary>
/// Gets the callback to invoke when the value changes.
/// </summary>
public Action<string, object?> OnChange { get; } = onChange;
/// <summary>
/// Gets a value indicating whether a STRING notification decodes as UTF-8 (else Windows-1252).
/// </summary>
public bool StringsUtf8 { get; init; }
/// <summary>
/// Gets the raw-bytes layout of an array notification, decoded by <see cref="TwinCATArrayCodec"/>; null for a scalar.
/// </summary>
public TwinCATArrayLayout? ArrayLayout { get; init; }
/// <summary>
/// Disposes the notification subscription.
/// </summary>
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,
};
}
/// <summary>Default <see cref="ITwinCATClientFactory"/> — one <see cref="AdsTwinCATClient"/> per call.</summary>
internal sealed class AdsTwinCATClientFactory : ITwinCATClientFactory
{
/// <inheritdoc />
public ITwinCATClient Create() => new AdsTwinCATClient();
}