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(); }