using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Text; using TwinCAT; using TwinCAT.Ads; using TwinCAT.Ads.SumCommand; using TwinCAT.Ads.TypeSystem; using TwinCAT.TypeSystem; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; /// /// 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 readonly AdsClient _client = new(); private readonly ConcurrentDictionary _notifications = new(); // Per-parent-symbol RMW locks. Keys are bounded by the writable-bit-tag cardinality // and are intentionally never removed — a leaking-but-bounded dictionary is simpler // than tracking liveness, matching the AbCip / Modbus / FOCAS pattern from #181. private readonly ConcurrentDictionary _bitWriteLocks = new(); // PR 2.2 — handle cache. Per-tag read/write resolves a symbolic path to an ADS // variable handle once, then issues every subsequent op against the handle. Smaller // AMS payloads (4-byte handle vs N-byte path) + skips name resolution in the runtime. // Lifetime is process-scoped: cleared on reconnect (EnsureConnected path), wiped on // a Symbol-Version-Invalid retry, and disposed on Dispose. PR 2.3 will wire a // proactive Symbol Version invalidation listener so stale handles after an online // change get evicted before the next read fails — until then, operators can call // FlushOptionalCachesAsync to wipe manually. private readonly ConcurrentDictionary _handleCache = new(); private bool _wasConnected; private readonly object _connectionStateGate = new(); // PR 2.3 — proactive Symbol-Version invalidation listener. The Beckhoff stack // surfaces a high-level event // (built on top of the SymbolVersion ADS notification, IndexGroup 0xF008) that // fires when the PLC's symbol table version counter increments — i.e. on full // re-initialisations after a download / activate. Registered after the AMS // session is up so the device server actually accepts the registration; we // unregister + clear the handle on Dispose. _symbolVersionRegistered guards // against double-registration if EnsureSymbolVersionListenerAsync is called // re-entrantly through ConnectAsync on a reconnect. // // Spec deviation: the original PR 2.3 plan called for a raw // AddDeviceNotificationAsync(AdsReservedIndexGroup.SymbolVersion, ...). Beckhoff // wrap that in IAdsSymbolChangedProvider on AdsClient so we get a typed // + Dispose-aware unregister // for free — same wire effect, smaller surface area. private bool _symbolVersionRegistered; private long _symbolVersionBumps; // Test-only counter — number of CreateVariableHandleAsync calls actually issued // (i.e. cache misses). Integration tests assert this stays at the unique-symbol // count after a second pass over the same set. internal int HandleCreateCount; /// Test-only — current size of the handle cache. internal int HandleCacheCount => _handleCache.Count; /// Test-only — total Symbol-Version bumps observed since process start. internal long SymbolVersionBumps => Interlocked.Read(ref _symbolVersionBumps); public AdsTwinCATClient() { _client.AdsNotificationEx += OnAdsNotificationEx; _client.AdsSymbolVersionChanged += OnAdsSymbolVersionChanged; } public bool IsConnected => _client.IsConnected; public async Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken) { if (_client.IsConnected) { // Idempotent. Still ensure the Symbol-Version listener is registered — first // ConnectAsync may have lost the registration if the AMS session dropped. await EnsureSymbolVersionListenerAsync(cancellationToken).ConfigureAwait(false); return; } _client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds); var netId = AmsNetId.Parse(address.NetId); // PR 2.2 — a fresh AMS session invalidates every cached handle (handle space is // per-session in the ADS device server). Clear before reconnect so any read that // raced with a transient drop never reuses a stale handle from the prior session. // Note: the handles for the prior session are gone with that session — no need to // call DeleteVariableHandleAsync, which would just fail with a transport error. var wasConnected = false; lock (_connectionStateGate) { wasConnected = _wasConnected; _wasConnected = false; } if (wasConnected || !_handleCache.IsEmpty) _handleCache.Clear(); // PR 2.3 — a reconnect drops the device-side notification registration. Mark // the listener as needing re-registration so EnsureSymbolVersionListenerAsync // re-arms it against the new session. _symbolVersionRegistered = false; _client.Connect(netId, address.Port); lock (_connectionStateGate) _wasConnected = _client.IsConnected; // PR 2.3 — register the Symbol-Version listener now that the AMS session is up. // Best-effort: a registration failure here doesn't fail the connect (the // DeviceSymbolVersionInvalid evict-and-retry path from PR 2.2 stays as the safety // net), it just means we won't get proactive cache invalidation until next reconnect. await EnsureSymbolVersionListenerAsync(cancellationToken).ConfigureAwait(false); } /// /// PR 2.3 — register the Beckhoff AdsSymbolVersionChanged event listener /// against the current AMS session. Idempotent: a second call while /// is true is a no-op so reconnect /// paths can call this freely without double-arming. Failures swallowed because /// the PR 2.2 reactive evict-and-retry path is still in place — proactive /// invalidation is an optimisation, not a correctness requirement. /// private async Task EnsureSymbolVersionListenerAsync(CancellationToken cancellationToken) { if (_symbolVersionRegistered) return; try { await _client.RegisterSymbolVersionChangedAsync(OnAdsSymbolVersionChanged, cancellationToken) .ConfigureAwait(false); _symbolVersionRegistered = true; } catch (OperationCanceledException) { throw; } catch { // Best-effort. The reactive evict-and-retry path (PR 2.2) catches the same // staleness; this is just an optimisation that lets us preempt the wasted // request that would otherwise come back DeviceSymbolVersionInvalid. } } /// /// PR 2.3 — Beckhoff fires this when the PLC's symbol-version counter increments, /// which happens on every full re-initialisation (download, activate-config, etc.). /// Every cached handle is invalid against the new symbol table, so we wipe the /// cache here. In-flight reads that already hold a handle will fall through to the /// PR 2.2 evict-and-retry path, /// which is exactly what we want — the proactive wipe just preempts the wasted /// round-trip on the next read for any symbol that didn't already have an in-flight op. /// private void OnAdsSymbolVersionChanged(object? sender, AdsSymbolVersionChangedEventArgs e) { Interlocked.Increment(ref _symbolVersionBumps); // Snapshot cache for best-effort wire-side cleanup, then clear so the next // EnsureHandleAsync re-resolves. Wire deletes are fire-and-forget — the device // server has already invalidated these handles, so the deletes typically just // bounce back with an error code we don't care about. var snapshot = _handleCache.ToArray(); _handleCache.Clear(); foreach (var kv in snapshot) { try { _ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None); } catch { /* best-effort; the new symbol-table version makes these handles dead anyway */ } } } public async Task<(object? value, uint status)> ReadValueAsync( string symbolPath, TwinCATDataType type, int? bitIndex, int[]? arrayDimensions, CancellationToken cancellationToken) { try { var clrType = MapToClrType(type); var readType = IsWholeArray(arrayDimensions) ? clrType.MakeArrayType() : clrType; // PR 2.2 — handle-based read. EnsureHandleAsync resolves through the cache; // SymbolVersionInvalid evicts + retries once with a fresh handle. var (rawValue, errorCode) = await ReadByHandleWithRetryAsync(symbolPath, readType, cancellationToken) .ConfigureAwait(false); if (errorCode != AdsErrorCode.NoError) return (null, TwinCATStatusMapper.MapAdsError((uint)errorCode)); var value = rawValue; if (IsWholeArray(arrayDimensions)) { value = PostProcessArray(type, value); return (value, TwinCATStatusMapper.Good); } if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool) value = ExtractBit(value, bit); value = PostProcessIecTime(type, value); return (value, TwinCATStatusMapper.Good); } catch (AdsErrorException ex) { return (null, TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode)); } } /// /// Resolve to a cached ADS variable handle (or create one /// on first use) and dispatch a . /// On evicts the cached handle /// + retries once with a freshly-created handle — covers the online-change race where /// the symbol survives but its descriptor moves. /// private async Task<(object? value, AdsErrorCode errorCode)> ReadByHandleWithRetryAsync( string symbolPath, Type readType, CancellationToken cancellationToken) { var handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false); var result = await _client.ReadAnyAsync(handle, readType, cancellationToken).ConfigureAwait(false); if (result.ErrorCode == AdsErrorCode.DeviceSymbolVersionInvalid) { EvictHandle(symbolPath); handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false); result = await _client.ReadAnyAsync(handle, readType, cancellationToken).ConfigureAwait(false); } return (result.Value, result.ErrorCode); } /// /// Mirror of for writes. Returns the final /// ; the caller maps that to an OPC UA status. /// private async Task WriteByHandleWithRetryAsync( string symbolPath, object value, CancellationToken cancellationToken) { var handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false); var result = await _client.WriteAnyAsync(handle, value, cancellationToken).ConfigureAwait(false); if (result.ErrorCode == AdsErrorCode.DeviceSymbolVersionInvalid) { EvictHandle(symbolPath); handle = await EnsureHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false); result = await _client.WriteAnyAsync(handle, value, cancellationToken).ConfigureAwait(false); } return result.ErrorCode; } /// /// Lookup-or-create the cached ADS handle for . The /// guarantees publication safety, /// but two concurrent callers on a cold key may both call /// . /// The loser's handle leaks for the lifetime of the process — acceptable cost /// given how narrow the race window is, and matched by the libplctag / S7 driver /// handle-cache patterns. /// internal async ValueTask EnsureHandleAsync(string symbolPath, CancellationToken cancellationToken) { if (_handleCache.TryGetValue(symbolPath, out var existing)) return existing; Interlocked.Increment(ref HandleCreateCount); var result = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false); if (result.ErrorCode != AdsErrorCode.NoError) throw new AdsErrorException( $"CreateVariableHandleAsync failed for '{symbolPath}'", result.ErrorCode); // GetOrAdd on a hit returns the winning handle; a loser-side DeleteVariableHandle here // would race against an in-flight read using that handle elsewhere in this method, so // we accept the small leak (one-time, per cold key) instead. return _handleCache.GetOrAdd(symbolPath, result.Handle); } /// /// Evict a single cached handle. Best-effort delete on the wire — the runtime may /// already have invalidated the handle (Symbol-Version-Invalid path), so we swallow /// transport / ADS errors here. /// private void EvictHandle(string symbolPath) { if (!_handleCache.TryRemove(symbolPath, out var handle)) return; try { // Fire-and-forget delete — the cache key is gone, the wire-side cleanup is // strictly courtesy. If the device server is in a state where the handle is // already dead, the delete will fail and we don't care. _ = _client.DeleteVariableHandleAsync(handle, CancellationToken.None); } catch { // Best-effort. } } private static bool IsWholeArray(int[]? arrayDimensions) => arrayDimensions is { Length: > 0 } && arrayDimensions.All(d => d > 0); /// Apply per-element IEC TIME/DATE post-processing to a flat array result. private static object? PostProcessArray(TwinCATDataType type, object? value) { if (value is not Array arr) return value; var elementProjector = type switch { TwinCATDataType.Time or TwinCATDataType.TimeOfDay or TwinCATDataType.Date or TwinCATDataType.DateTime => (Func)(v => PostProcessIecTime(type, v)), _ => null, }; if (elementProjector is null) return arr; // IEC time post-processing changes the CLR element type (uint -> TimeSpan / DateTime). // Project into an object[] so the array element type matches the projected values. var projected = new object?[arr.Length]; for (var i = 0; i < arr.Length; i++) projected[i] = elementProjector(arr.GetValue(i)); return projected; } public async Task WriteValueAsync( string symbolPath, TwinCATDataType type, int? bitIndex, int[]? arrayDimensions, object? value, CancellationToken cancellationToken) { if (IsWholeArray(arrayDimensions)) return TwinCATStatusMapper.BadNotSupported; // PR-1.4 ships read-only whole-array if (bitIndex is int bit && type == TwinCATDataType.Bool) return await WriteBitInWordAsync(symbolPath, bit, value, cancellationToken) .ConfigureAwait(false); try { var converted = ConvertForWrite(type, value); // PR 2.2 — handle-based write with SymbolVersionInvalid evict-and-retry. var errorCode = await WriteByHandleWithRetryAsync(symbolPath, converted, cancellationToken) .ConfigureAwait(false); return errorCode == AdsErrorCode.NoError ? TwinCATStatusMapper.Good : TwinCATStatusMapper.MapAdsError((uint)errorCode); } catch (AdsErrorException ex) { return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode); } } /// /// Read-modify-write a single bit within an integer parent word. /// is the bit-selector path (e.g. Flags.3); the parent is the same path with the /// .N suffix stripped and is read/written as a UDINT — TwinCAT handles narrower /// parents (BYTE/WORD) implicitly through the UDINT projection. /// /// /// Concurrent bit writers against the same parent are serialised through a per-parent /// to prevent torn reads/writes. Mirrors the AbCip / Modbus / /// FOCAS bit-RMW pattern. /// private async Task WriteBitInWordAsync( string symbolPath, int bit, object? value, CancellationToken cancellationToken) { var parentPath = TryGetParentSymbolPath(symbolPath); if (parentPath is null) return TwinCATStatusMapper.BadNotSupported; var setBit = Convert.ToBoolean(value); var rmwLock = _bitWriteLocks.GetOrAdd(parentPath, _ => new SemaphoreSlim(1, 1)); await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { // PR 2.2 — RMW round-trip flows through the same handle cache so that the // parent word's resolved handle is reused on subsequent bit writes too. var (rawCurrent, readErr) = await ReadByHandleWithRetryAsync(parentPath, typeof(uint), cancellationToken) .ConfigureAwait(false); if (readErr != AdsErrorCode.NoError) return TwinCATStatusMapper.MapAdsError((uint)readErr); var current = Convert.ToUInt32(rawCurrent ?? 0u); var updated = ApplyBit(current, bit, setBit); var writeErr = await WriteByHandleWithRetryAsync(parentPath, updated, cancellationToken) .ConfigureAwait(false); return writeErr == AdsErrorCode.NoError ? TwinCATStatusMapper.Good : TwinCATStatusMapper.MapAdsError((uint)writeErr); } catch (AdsErrorException ex) { return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode); } finally { rmwLock.Release(); } } /// /// Strip the trailing .N bit selector from a TwinCAT symbol path. Returns /// null when the path has no parent (single segment / leading dot). /// internal static string? TryGetParentSymbolPath(string symbolPath) { var dot = symbolPath.LastIndexOf('.'); return dot <= 0 ? null : symbolPath.Substring(0, dot); } /// Set or clear bit in . internal static uint ApplyBit(uint word, int bit, bool setBit) { var mask = 1u << bit; return setBit ? (word | mask) : (word & ~mask); } 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 100ns units. AdsTransMode.OnChange // fires when the value differs; OnCycle fires every cycle. OnChange is the right default // for OPC UA data-change semantics — the PLC already has the best view of "has this // changed" so we let it decide. var cycleTicks = (uint)Math.Max(1, cycleTime.Ticks / TimeSpan.TicksPerMillisecond * 10_000); var settings = new NotificationSettings(AdsTransMode.OnChange, (int)cycleTicks, 0); // AddDeviceNotificationExAsync returns Task; AdsNotificationEx fires // with the handle as part of the event args so we use the handle as the correlation // key into _notifications. var result = await _client.AddDeviceNotificationExAsync( symbolPath, settings, userData: null, clrType, args: null, cancellationToken) .ConfigureAwait(false); if (result.ErrorCode != AdsErrorCode.NoError) throw new InvalidOperationException( $"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}"); var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle); _notifications[result.Handle] = reg; return reg; } private void OnAdsNotificationEx(object? sender, AdsNotificationExEventArgs args) { if (!_notifications.TryGetValue(args.Handle, out var reg)) return; var value = args.Value; if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool) value = ExtractBit(value, bit); value = PostProcessIecTime(reg.Type, value); try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ } } internal async Task DeleteNotificationAsync(uint handle, CancellationToken cancellationToken) { _notifications.TryRemove(handle, out _); try { await _client.DeleteDeviceNotificationAsync(handle, cancellationToken).ConfigureAwait(false); } catch { /* best-effort tear-down; target may already be gone */ } } public async IAsyncEnumerable BrowseSymbolsAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { // SymbolLoaderFactory downloads the symbol-info blob once then iterates locally — the // async surface on this interface is for our callers, not for the underlying call which // is effectively sync on top of the already-open AdsClient. var settings = new SymbolLoaderSettings(SymbolsLoadMode.Flat); var loader = SymbolLoaderFactory.Create(_client, settings); await Task.Yield(); // honors the async surface; pragmatic given the loader itself is sync foreach (ISymbol symbol in loader.Symbols) { if (cancellationToken.IsCancellationRequested) yield break; var mapped = ResolveSymbolDataType(symbol.DataType); var readOnly = !IsSymbolWritable(symbol); yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly); } } /// /// Resolve an IEC atomic for a TwinCAT symbol's data type. /// ENUMs surface as their underlying integer (the enum's BaseType); ALIAS chains /// are walked recursively via until an atomic primitive /// is reached. POINTER / REFERENCE / INTERFACE / UNION / STRUCT / FB / array types remain /// out of scope and surface as null so the caller skips them. /// /// /// Recursion is bounded at as a defence against pathological /// cycles in the type graph — TwinCAT shouldn't emit those, but this is cheap insurance. /// internal const int MaxAliasDepth = 16; internal static TwinCATDataType? ResolveSymbolDataType(IDataType? dataType) { var current = dataType; for (var depth = 0; current is not null && depth < MaxAliasDepth; depth++) { switch (current.Category) { case DataTypeCategory.Primitive: case DataTypeCategory.String: return MapSymbolTypeName(current.Name); case DataTypeCategory.Enum: case DataTypeCategory.Alias: // IEnumType : IAliasType, so BaseType walk handles both. For an enum the // base type is the underlying integer; for alias chains it's the next link. if (current is IAliasType alias) { current = alias.BaseType; continue; } return null; default: // POINTER / REFERENCE / INTERFACE / UNION / STRUCT / ARRAY / FB / Program — // explicitly out of scope at this PR. return null; } } return null; } private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch { "BOOL" or "BIT" => TwinCATDataType.Bool, "SINT" or "BYTE" => TwinCATDataType.SInt, "USINT" => TwinCATDataType.USInt, "INT" or "WORD" => TwinCATDataType.Int, "UINT" => TwinCATDataType.UInt, "DINT" or "DWORD" => TwinCATDataType.DInt, "UDINT" => TwinCATDataType.UDInt, "LINT" or "LWORD" => TwinCATDataType.LInt, "ULINT" => TwinCATDataType.ULInt, "REAL" => TwinCATDataType.Real, "LREAL" => TwinCATDataType.LReal, "STRING" => TwinCATDataType.String, "WSTRING" => TwinCATDataType.WString, "TIME" => TwinCATDataType.Time, "DATE" => TwinCATDataType.Date, "DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime, "TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay, _ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope }; private static bool IsSymbolWritable(ISymbol symbol) { // SymbolAccessRights is a flags enum — the Write bit indicates a writable symbol. // When the symbol implementation doesn't surface it, assume writable + let the PLC // return AccessDenied at write time. if (symbol is Symbol s) return (s.AccessRights & SymbolAccessRights.Write) != 0; return true; } public async Task> ReadValuesAsync( IReadOnlyList reads, CancellationToken cancellationToken) { if (reads.Count == 0) return Array.Empty<(object?, uint)>(); // PR 2.2 deviation: bulk path stays on symbolic Sum-command (SumInstancePathAnyTypeRead / // SumWriteBySymbolPath). Beckhoff also exposes SumHandleRead / SumWriteByHandle, but // wiring the cached handles into them changes the request layout substantially + // would either need to reuse handles created on the per-tag path (tying lifetimes) // or maintain a parallel handle batch — neither pulls weight against PR 2.1's already // 10-20× win. Tracked as a follow-up for the Phase-2 perf sweep. // Build the (path, AnyTypeSpecifier) request envelope. SumInstancePathAnyTypeRead // batches all paths into a single ADS Sum-read round-trip (IndexGroup 0xF080 = read // multiple items by symbol name with ANY-type marshalling). var typeSpecs = new List<(string instancePath, AnyTypeSpecifier spec)>(reads.Count); foreach (var r in reads) typeSpecs.Add((r.SymbolPath, BuildAnyTypeSpecifier(r.Type, r.StringLength))); var sumCmd = new SumInstancePathAnyTypeRead(_client, typeSpecs); try { var sumResult = await sumCmd.ReadAsync(cancellationToken).ConfigureAwait(false); // ResultSumValues2.ValueResults is a per-item array with Source / Value / // ErrorCode. Even when the overall ADS request succeeds, individual sub-items can // carry their own ADS error (e.g. SymbolNotFound). var output = new (object? value, uint status)[reads.Count]; var valueResults = sumResult.ValueResults; for (var i = 0; i < reads.Count; i++) { var vr = valueResults[i]; if (vr.ErrorCode != 0) { output[i] = (null, TwinCATStatusMapper.MapAdsError((uint)vr.ErrorCode)); continue; } var raw = vr.Value; output[i] = (PostProcessIecTime(reads[i].Type, raw), TwinCATStatusMapper.Good); } return output; } catch (AdsErrorException ex) { // Whole-batch failure (no symbol-server ack, router unreachable, etc.). Map the // overall ADS status onto every entry so callers see uniform status — partial- // success marshalling lives in the success branch above. var status = TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode); var failed = new (object? value, uint status)[reads.Count]; for (var i = 0; i < reads.Count; i++) failed[i] = (null, status); return failed; } } public async Task> WriteValuesAsync( IReadOnlyList writes, CancellationToken cancellationToken) { if (writes.Count == 0) return Array.Empty(); // SumWriteBySymbolPath internally requests symbol handles + issues a single sum-write // (IndexGroup 0xF081) carrying all values. One AMS round-trip for N writes. var paths = new List(writes.Count); var values = new object[writes.Count]; for (var i = 0; i < writes.Count; i++) { paths.Add(writes[i].SymbolPath); values[i] = ConvertForWrite(writes[i].Type, writes[i].Value); } var sumCmd = new SumWriteBySymbolPath(_client, paths); try { var result = await sumCmd.WriteAsync(values, cancellationToken).ConfigureAwait(false); var output = new uint[writes.Count]; var subErrors = result.SubErrors; for (var i = 0; i < writes.Count; i++) { // SubErrors can be null when the overall request failed before sub-dispatch — // surface the OverallError on every slot in that case. var code = subErrors is { Length: > 0 } && i < subErrors.Length ? (uint)subErrors[i] : (uint)result.ErrorCode; output[i] = TwinCATStatusMapper.MapAdsError(code); } return output; } catch (AdsErrorException ex) { var status = TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode); var failed = new uint[writes.Count]; for (var i = 0; i < writes.Count; i++) failed[i] = status; return failed; } } /// /// Build an for one bulk-read entry. STRING uses ASCII + /// the supplied ; WSTRING uses Unicode (UTF-16). All other /// types resolve to a primitive CLR type via . IEC time/date /// symbols flow as their underlying UDINT (matching the per-tag path in /// ) and are post-processed CLR-side after the sum-read. /// private static AnyTypeSpecifier BuildAnyTypeSpecifier(TwinCATDataType type, int stringLength) => type switch { TwinCATDataType.String => new AnyTypeSpecifier(typeof(string), stringLength, Encoding.ASCII), TwinCATDataType.WString => new AnyTypeSpecifier(typeof(string), stringLength, Encoding.Unicode), _ => new AnyTypeSpecifier(MapToClrType(type)), }; public void Dispose() { _client.AdsNotificationEx -= OnAdsNotificationEx; // PR 2.3 — unregister the Symbol-Version listener. Best-effort: by the time we're // disposing, the AMS session is already shutting down so the device server may // refuse the unregister. Either way, AdsClient.Dispose tears the underlying // notification subscription down regardless. if (_symbolVersionRegistered) { try { _client.UnregisterSymbolVersionChanged(OnAdsSymbolVersionChanged); } catch { /* best-effort */ } _symbolVersionRegistered = false; } _client.AdsSymbolVersionChanged -= OnAdsSymbolVersionChanged; _notifications.Clear(); // PR 2.2 — release every cached handle on the wire as a good citizen. Best-effort // and bounded to a short window so a hung router doesn't block process shutdown: // each delete is fire-and-forget, errors swallowed. The session itself is about to // tear down anyway, so the device server will reclaim everything regardless. foreach (var kv in _handleCache) { try { _ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None); } catch { // Per-entry failures are expected on a closing connection. } } _handleCache.Clear(); _client.Dispose(); } /// /// PR 2.2 — flush all process-scoped optional caches (handle cache today). A /// proactive Symbol Version invalidation listener arrives in PR 2.3 — until then, /// operators / 2.3-aware callers can wipe the cache manually after a known online /// change. /// public Task FlushOptionalCachesAsync() { // Best-effort delete on the wire — a held handle won't survive a redeploy anyway, // but cleaning up matches the Dispose convention. var snapshot = _handleCache.ToArray(); _handleCache.Clear(); foreach (var kv in snapshot) { try { _ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None); } catch { // Best-effort. } } return Task.CompletedTask; } private sealed class NotificationRegistration( string symbolPath, TwinCATDataType type, int? bitIndex, Action 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), }; internal static object ConvertForWrite(TwinCATDataType type, object? value) => type switch { TwinCATDataType.Bool => Convert.ToBoolean(value), TwinCATDataType.SInt => Convert.ToSByte(value), TwinCATDataType.USInt => Convert.ToByte(value), TwinCATDataType.Int => Convert.ToInt16(value), TwinCATDataType.UInt => Convert.ToUInt16(value), TwinCATDataType.DInt => Convert.ToInt32(value), TwinCATDataType.UDInt => Convert.ToUInt32(value), TwinCATDataType.LInt => Convert.ToInt64(value), TwinCATDataType.ULInt => Convert.ToUInt64(value), TwinCATDataType.Real => Convert.ToSingle(value), TwinCATDataType.LReal => Convert.ToDouble(value), TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty, // IEC durations (TIME / TOD) accept TimeSpan / Duration-as-Double-ms / raw UDINT. // IEC timestamps (DATE / DT) accept DateTime (UTC) / raw UDINT seconds-since-epoch. TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DurationToUDInt(value), TwinCATDataType.Date or TwinCATDataType.DateTime => DateTimeToUDInt(value), _ => throw new NotSupportedException($"TwinCATDataType {type} not writable."), }; // IEC 61131-3 epoch is 1970-01-01 UTC for DATE / DT; TIME / TOD are unsigned ms counters. private static readonly DateTime IecEpochUtc = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); /// /// Convert the raw UDINT wire value for IEC TIME/DATE/DT/TOD into the native CLR type /// surfaced upstream — TimeSpan for durations, DateTime (UTC) for timestamps. Other /// types pass through unchanged. /// internal static object? PostProcessIecTime(TwinCATDataType type, object? value) { if (value is null) return null; var raw = TryGetUInt32(value); if (raw is null) return value; return type switch { // TIME / TOD — UDINT milliseconds. TwinCATDataType.Time or TwinCATDataType.TimeOfDay => TimeSpan.FromMilliseconds(raw.Value), // DT — UDINT seconds since 1970-01-01 UTC. TwinCATDataType.DateTime => IecEpochUtc.AddSeconds(raw.Value), // DATE — UDINT seconds since 1970-01-01 UTC, but TwinCAT runtimes pin the time // component to midnight; pass through the same conversion so we get a date-only // value at midnight UTC. TwinCATDataType.Date => IecEpochUtc.AddSeconds(raw.Value), _ => value, }; } private static uint? TryGetUInt32(object value) => value switch { uint u => u, int i when i >= 0 => (uint)i, ushort us => (uint)us, short s when s >= 0 => (uint)s, long l when l >= 0 && l <= uint.MaxValue => (uint)l, ulong ul when ul <= uint.MaxValue => (uint)ul, _ => null, }; private static uint DurationToUDInt(object? value) => value switch { TimeSpan ts => (uint)Math.Max(0, ts.TotalMilliseconds), // OPC UA Duration on the wire is a Double in milliseconds. double d => (uint)Math.Max(0, d), float f => (uint)Math.Max(0, f), _ => Convert.ToUInt32(value), }; private static uint DateTimeToUDInt(object? value) { if (value is DateTime dt) { var utc = dt.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) : dt.ToUniversalTime(); var seconds = (long)(utc - IecEpochUtc).TotalSeconds; if (seconds < 0 || seconds > uint.MaxValue) throw new ArgumentOutOfRangeException(nameof(value), "DATE/DT value out of UDINT epoch range (1970-01-01..2106-02-07 UTC)."); return (uint)seconds; } return Convert.ToUInt32(value); } private static bool ExtractBit(object? rawWord, int bit) => rawWord switch { short s => (s & (1 << bit)) != 0, ushort us => (us & (1 << bit)) != 0, int i => (i & (1 << bit)) != 0, uint ui => (ui & (1u << bit)) != 0, long l => (l & (1L << bit)) != 0, ulong ul => (ul & (1UL << bit)) != 0, _ => false, }; } /// Default — one per call. internal sealed class AdsTwinCATClientFactory : ITwinCATClientFactory { public ITwinCATClient Create() => new AdsTwinCATClient(); }