using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Threading.Channels; using TwinCAT; using TwinCAT.Ads; 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 { // Bounded so a slow downstream consumer cannot back the AMS router thread up — the // router thread enqueues and returns immediately (Driver.TwinCAT-008). 50k matches the // Galaxy EventPump default; ~500 notifications/connection is the ADS ceiling so this is // generous headroom against bursty change storms. 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; 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; 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, 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. if (bitIndex is int bit && type == TwinCATDataType.Bool) { var parent = StripBitSuffix(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); } 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 (result.Value, TwinCATStatusMapper.Good); } catch (AdsErrorException ex) { return (null, MapAndSignal((uint)ex.ErrorCode)); } } private static string StripBitSuffix(string symbolPath) { var lastDot = symbolPath.LastIndexOf('.'); if (lastDot < 0) return symbolPath; return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _) ? symbolPath[..lastDot] : symbolPath; } public async Task WriteValueAsync( string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken cancellationToken) { if (bitIndex is int && type == TwinCATDataType.Bool) throw new NotSupportedException( "BOOL-within-word writes require read-modify-write; tracked in task #181."); 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); return state.ErrorCode == AdsErrorCode.NoError; } catch { return false; } } public async Task AddNotificationAsync( string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime, Action onChange, CancellationToken cancellationToken) { var clrType = MapToClrType(type); // NotificationSettings takes cycle + max-delay in milliseconds (Beckhoff InfoSys // tcadsnetref/7313319051 — "The unit is 1ms"). AdsTransMode.OnChange fires when // the value differs; OnCycle fires every cycle. OnChange is the right default for // OPC UA data-change semantics — the PLC already has the best view of "has this // changed" so we let it decide. var cycleMs = (int)Math.Max(1, cycleTime.TotalMilliseconds); var settings = new NotificationSettings(AdsTransMode.OnChange, cycleMs, 0); // 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( symbolPath, settings, userData: null, clrType, args: null, cancellationToken) .ConfigureAwait(false); if (result.ErrorCode != AdsErrorCode.NoError) throw new InvalidOperationException( $"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}"); var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle); _notifications[result.Handle] = reg; return reg; } /// /// Runs on the AMS router thread. Does the cheap bit-extraction /// decode then enqueues — no driver logic, no consumer callbacks — so a slow consumer /// can never stall ADS notification delivery for the rest of the process /// (Driver.TwinCAT-008). 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 && value is not bool) value = ExtractBit(value, bit); _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. } } 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. var settings = new SymbolLoaderSettings(SymbolsLoadMode.Flat); var loader = SymbolLoaderFactory.Create(_client, settings); await Task.Yield(); // honors the async surface; pragmatic given the loader itself is sync foreach (ISymbol symbol in loader.Symbols) { // ThrowIfCancellationRequested — not yield break — so a cancelled browse propagates // as OperationCanceledException rather than a silent clean completion. DiscoverAsync // has an explicit catch(OperationCanceledException){ throw; } to surface this // distinctly from a genuine browse failure; a yield break would let a partial // symbol set appear as a fully successful discovery (Driver.TwinCAT-010). cancellationToken.ThrowIfCancellationRequested(); var mapped = MapSymbolTypeName(symbol.DataType?.Name); var readOnly = !IsSymbolWritable(symbol); yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly); } } private static TwinCATDataType? MapSymbolTypeName(string? typeName) { if (typeName is null) return null; // 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. var paren = typeName.IndexOf('('); var bare = paren > 0 ? typeName[..paren] : typeName; return bare switch { "BOOL" or "BIT" => TwinCATDataType.Bool, "SINT" or "BYTE" => TwinCATDataType.SInt, "USINT" => TwinCATDataType.USInt, "INT" or "WORD" => TwinCATDataType.Int, "UINT" => TwinCATDataType.UInt, "DINT" or "DWORD" => TwinCATDataType.DInt, "UDINT" => TwinCATDataType.UDInt, "LINT" or "LWORD" => TwinCATDataType.LInt, "ULINT" => TwinCATDataType.ULInt, "REAL" => TwinCATDataType.Real, "LREAL" => TwinCATDataType.LReal, "STRING" => TwinCATDataType.String, "WSTRING" => TwinCATDataType.WString, "TIME" => TwinCATDataType.Time, "DATE" => TwinCATDataType.Date, "DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime, "TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay, _ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope }; } private static bool IsSymbolWritable(ISymbol symbol) { // SymbolAccessRights is a flags enum — the Write bit indicates a writable symbol. // When the symbol implementation doesn't surface it, assume writable + let the PLC // return AccessDenied at write time. if (symbol is Symbol s) return (s.AccessRights & SymbolAccessRights.Write) != 0; return true; } public 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 { public string SymbolPath { get; } = symbolPath; public TwinCATDataType Type { get; } = type; public int? BitIndex { get; } = bitIndex; public Action OnChange { get; } = onChange; public void Dispose() { // Fire-and-forget AMS call — caller has already committed to the tear-down. _ = owner.DeleteNotificationAsync(handle, CancellationToken.None); } } private static Type MapToClrType(TwinCATDataType type) => type switch { TwinCATDataType.Bool => typeof(bool), TwinCATDataType.SInt => typeof(sbyte), TwinCATDataType.USInt => typeof(byte), TwinCATDataType.Int => typeof(short), TwinCATDataType.UInt => typeof(ushort), TwinCATDataType.DInt => typeof(int), TwinCATDataType.UDInt => typeof(uint), TwinCATDataType.LInt => typeof(long), TwinCATDataType.ULInt => typeof(ulong), TwinCATDataType.Real => typeof(float), TwinCATDataType.LReal => typeof(double), TwinCATDataType.String or TwinCATDataType.WString => typeof(string), TwinCATDataType.Time or TwinCATDataType.Date or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => typeof(uint), _ => typeof(int), }; private 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 => Convert.ToUInt32(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(); }