using System.Collections.Concurrent; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; /// /// TwinCAT ADS driver — talks to Beckhoff PLC runtimes (TC2 + TC3) via AMS / ADS. PR 1 ships /// the skeleton; read / write / discover / subscribe / probe / host- /// resolver land in PRs 2 and 3. /// public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, IDisposable, IAsyncDisposable { // Mutable so ReinitializeAsync can apply a new config generation. The constructor seeds // it; InitializeAsync re-parses driverConfigJson over the top of it. private TwinCATDriverOptions _options; private readonly string _driverInstanceId; private readonly ITwinCATClientFactory _clientFactory; private readonly ILogger _logger; private readonly PollGroupEngine _poll; // ConcurrentDictionary so ShutdownAsync (Clear) and ReadAsync/WriteAsync/SubscribeAsync // (TryGetValue) don't race — plain Dictionary is not safe for concurrent read+write. private readonly ConcurrentDictionary _devices = new(StringComparer.OrdinalIgnoreCase); // v3: the authored RawPath → definition table. Keyed case-sensitive ordinal (the RawPath is // the exact wire reference). Built in InitializeAsync from _options.RawTags via the pure mapper. private readonly ConcurrentDictionary _tagsByRawPath = new(StringComparer.Ordinal); // Per-parent-word RMW gate for BOOL-within-word writes: a single-bit write is a // read-modify-write of the parent word (TwinCAT's symbol table doesn't expose "Word.N"), // so concurrent bit-writers to the same word must serialise or they lose each other's // updates. Keyed by device + parent symbol. (Cannot guard against the PLC program writing // the word between our read and write — inherent to RMW.) private readonly ConcurrentDictionary _bitRmwLocks = new(StringComparer.OrdinalIgnoreCase); // v3: resolves a read/write/subscribe fullReference (always a RawPath) to a tag definition by a // single hit on the authored _tagsByRawPath table. A miss is a miss — the driver surfaces // BadNodeIdUnknown (no blob-parse fallback). private readonly EquipmentTagRefResolver _resolver; private DriverHealth _health = new(DriverState.Unknown, null, null); /// Occurs when a subscribed tag value changes. public event EventHandler? OnDataChange; /// Occurs when a device host connectivity status changes. public event EventHandler? OnHostStatusChanged; /// Occurs when the Galaxy object hierarchy or TwinCAT symbol table is rediscovered. public event EventHandler? OnRediscoveryNeeded; /// Initializes a new instance of the class. /// Driver configuration options. /// Unique driver instance identifier. /// Optional ADS client factory; defaults to . /// Optional logger; defaults to . public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId, ITwinCATClientFactory? clientFactory = null, ILogger? logger = null) { ArgumentNullException.ThrowIfNull(options); _options = options; _driverInstanceId = driverInstanceId; _clientFactory = clientFactory ?? new AdsTwinCATClientFactory(); _logger = logger ?? NullLogger.Instance; _resolver = new EquipmentTagRefResolver( r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null); _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)), onError: HandlePollError, backoffCap: PollBackoffCap); } /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30); /// /// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and /// degrades to preserving LastSuccessfulRead. /// Never downgrades a state (a stronger, config-level signal). /// /// The exception caught by the poll engine. internal void HandlePollError(Exception ex) { _logger.LogWarning(ex, "TwinCAT poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); var current = _health; if (current.State != DriverState.Faulted) _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message); } /// public string DriverInstanceId => _driverInstanceId; /// public string DriverType => "TwinCAT"; /// public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { _health = new DriverHealth(DriverState.Initializing, null, null); try { // Apply the supplied config generation. A blank or content-free document keeps the // constructor-seeded options — that path covers callers that have already // materialised options up front (the factory passes both, in agreement). if (!string.IsNullOrWhiteSpace(driverConfigJson)) { var parsed = TwinCATDriverFactoryExtensions.ParseOptions(driverConfigJson, _driverInstanceId); if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0) _options = parsed; } foreach (var device in _options.Devices) { var addr = TwinCATAmsAddress.TryParse(device.HostAddress) ?? throw new InvalidOperationException( $"TwinCAT device has invalid HostAddress '{device.HostAddress}' — expected 'ads://{{netId}}:{{port}}'."); _devices[device.HostAddress] = new DeviceState(addr, device); } // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig // blob is mapped by the pure factory; the entry's WriteIdempotent flag AND its owning // DeviceName are threaded onto the def (both live on the RawTagEntry, not inside the blob). // DeviceName is resolved to the target's live host (def.DeviceHostAddress) via ResolveDeviceHost // so the existing device routing (_devices keyed by HostAddress) is unchanged. A mapper miss is // skipped (logged), never thrown — a bad tag must not fail the whole driver init. foreach (var entry in _options.RawTags) { if (TwinCATTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def)) _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent, DeviceHostAddress = ResolveDeviceHost(entry.DeviceName), }; else _logger.LogWarning( "TwinCAT tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", _driverInstanceId, entry.RawPath); } if (_options.Probe.Enabled) { foreach (var state in _devices.Values) { state.ProbeCts = new CancellationTokenSource(); var ct = state.ProbeCts.Token; // Store the task so ShutdownAsync can await it. state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct); } } _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } catch (Exception ex) { _health = new DriverHealth(DriverState.Faulted, null, ex.Message); throw; } return Task.CompletedTask; } /// public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { await ShutdownAsync(cancellationToken).ConfigureAwait(false); await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); } /// public async Task ShutdownAsync(CancellationToken cancellationToken) { // Native subs first — disposing the handles is cheap + lets the client close its // notifications before the AdsClient itself goes away. foreach (var sub in _nativeSubs.Values) foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } } _nativeSubs.Clear(); await _poll.DisposeAsync().ConfigureAwait(false); // Cancel every probe loop and await its task before disposing the client + gate so the // loop can never touch a disposed object. foreach (var state in _devices.Values) { try { state.ProbeCts?.Cancel(); } catch { } if (state.ProbeTask is Task pt) { try { await pt.ConfigureAwait(false); } catch (OperationCanceledException) { /* expected — probe loop exits on cancel */ } catch { /* other probe errors are not fatal to shutdown */ } } state.ProbeCts?.Dispose(); state.ProbeCts = null; state.DisposeClient(); state.DisposeGate(); } _devices.Clear(); _tagsByRawPath.Clear(); _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry // Dispose + clear the per-parent-word RMW gates so ReinitializeAsync cycles don't orphan // their SemaphoreSlim instances (each leaks a wait handle once contended). foreach (var sem in _bitRmwLocks.Values) sem.Dispose(); _bitRmwLocks.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } /// public DriverHealth GetHealth() => _health; /// // This driver holds no flushable symbol cache — BrowseSymbolsAsync streams and discards; the // footprint reflects live allocations only: ~256 bytes per pre-declared tag (tag-definition // record + dictionary overhead) and ~512 bytes per active native subscription. public long GetMemoryFootprint() => (_tagsByRawPath.Count * 256L) + (_nativeSubs.Count * 512L); /// // No flushable cache exists in this driver — the symbol table is streamed fresh on every // DiscoverAsync call. This is a no-op but is deliberately present so Core's cache-budget // enforcement sees a compliant Tier-A driver. public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// Gets the count of configured devices. internal int DeviceCount => _devices.Count; /// Gets the device state for the specified host address. /// The ADS host address. /// Device state or null if not found. internal DeviceState? GetDeviceState(string hostAddress) => _devices.TryGetValue(hostAddress, out var s) ? s : null; // ---- IReadable ---- /// public async Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(fullReferences); var now = DateTime.UtcNow; var results = new DataValueSnapshot[fullReferences.Count]; for (var i = 0; i < fullReferences.Count; i++) { var reference = fullReferences[i]; if (!_resolver.TryResolve(reference, out var def)) { results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now); continue; } if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now); continue; } try { var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; // An array-typed tag (def.ArrayLength != null) drives a native 1-D ADS array read; // the boxed result is an element-typed CLR array. Scalar tags pass null // (scalar path) — bit-indexed BOOLs are inherently scalar so the client ignores // arrayCount when a bitIndex is present (Phase 4c). var (value, status) = await client.ReadValueAsync( symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken) .ConfigureAwait(false); results[i] = new DataValueSnapshot(value, status, now, now); if (status == TwinCATStatusMapper.Good) _health = new DriverHealth(DriverState.Healthy, now, null); else { _logger.LogWarning( "TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'", _driverInstanceId, status, reference); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, $"ADS status {status:X8} reading {reference}"); } } catch (OperationCanceledException) { throw; } catch (Exception ex) { results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } return results; } // ---- IWritable ---- /// public async Task> WriteAsync( IReadOnlyList writes, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(writes); var results = new WriteResult[writes.Count]; for (var i = 0; i < writes.Count; i++) { var w = writes[i]; if (!_resolver.TryResolve(w.FullReference, out var def)) { results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown); continue; } if (!def.Writable) { results[i] = new WriteResult(TwinCATStatusMapper.BadNotWritable); continue; } if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown); continue; } try { var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); // BOOL-within-word write — read-modify-write of the parent word. Mirrors the bit-read // (AdsTwinCATClient.ReadValueAsync) which reads the parent as uint: read the parent as // UDInt (-> uint), flip the bit, write it back, all under a per-parent lock. if (def.DataType == TwinCATDataType.Bool && parsed?.BitIndex is int bit) { var parentPath = (parsed with { BitIndex = null }).ToAdsSymbolName(); var gate = _bitRmwLocks.GetOrAdd( $"{def.DeviceHostAddress}|{parentPath}", static _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { var (parentValue, readStatus) = await client.ReadValueAsync( parentPath, TwinCATDataType.UDInt, null, null, cancellationToken).ConfigureAwait(false); if (readStatus != TwinCATStatusMapper.Good) { results[i] = new WriteResult(readStatus); } else if (parentValue is null) { // Good status but no value — treating null as 0 would write a zeroed // word and clear every bit set on the device. Surface a Bad status and // write nothing rather than corrupt the parent word. results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError); } else { var word = Convert.ToUInt32(parentValue); var updated = Convert.ToBoolean(w.Value) ? word | (1u << bit) : word & ~(1u << bit); var writeStatus = await client.WriteValueAsync( parentPath, TwinCATDataType.UDInt, null, updated, cancellationToken).ConfigureAwait(false); results[i] = new WriteResult(writeStatus); } } finally { gate.Release(); } continue; } var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; var status = await client.WriteValueAsync( symbolName, def.DataType, parsed?.BitIndex, w.Value, cancellationToken).ConfigureAwait(false); results[i] = new WriteResult(status); } catch (OperationCanceledException) { throw; } catch (NotSupportedException nse) { results[i] = new WriteResult(TwinCATStatusMapper.BadNotSupported); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); } catch (Exception ex) when (ex is FormatException or InvalidCastException) { results[i] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch); } catch (OverflowException) { results[i] = new WriteResult(TwinCATStatusMapper.BadOutOfRange); } catch (Exception ex) { results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } return results; } // ---- ITagDiscovery ---- /// // Run-once: DiscoverAsync emits pre-declared tags and (when EnableControllerBrowse is set) // fully awaits the controller symbol browse within the single call, streaming the complete // node set in one pass — nothing fills in asynchronously after connect, so a single // discovery pass is sufficient. public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; /// ADS symbol upload is real device enumeration — config-gated on /// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time. public bool SupportsOnlineDiscovery => true; /// public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(builder); var root = builder.Folder("TwinCAT", "TwinCAT"); foreach (var device in _options.Devices) { var label = device.DeviceName ?? device.HostAddress; var deviceFolder = root.Folder(device.HostAddress, label); // Authored raw tags (mapped into the RawPath → def table at Initialize) — always emitted // as the authoritative config path. The def's Name is the RawPath, used for both the browse // name and the driver FullName; it is routed to this device by def.DeviceHostAddress. var tagsForDevice = _tagsByRawPath.Values.Where(t => string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)); foreach (var tag in tagsForDevice) { deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo( FullName: tag.Name, DriverDataType: tag.DataType.ToDriverDataType(), // A pre-declared tag with a positive ArrayLength is a 1-D array node; null // (or non-positive) stays scalar (Phase 4c). IsArray: tag.ArrayLength is > 0, ArrayDim: tag.ArrayLength is > 0 ? (uint)tag.ArrayLength.Value : null, SecurityClass: tag.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false, WriteIdempotent: tag.WriteIdempotent)); } // Controller-side symbol browse — opt-in. Falls back to pre-declared-only on any // client-side error so a flaky symbol-table download doesn't block discovery. if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state)) { IAddressSpaceBuilder? discoveredFolder = null; try { var client = await EnsureConnectedAsync(state, cancellationToken).ConfigureAwait(false); await foreach (var sym in client.BrowseSymbolsAsync(cancellationToken).ConfigureAwait(false)) { if (TwinCATSystemSymbolFilter.IsSystemSymbol(sym.InstancePath)) continue; if (sym.DataType is not TwinCATDataType dt) continue; // unsupported type discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered"); discoveredFolder.Variable(sym.InstancePath, sym.InstancePath, new DriverAttributeInfo( FullName: sym.InstancePath, DriverDataType: dt.ToDriverDataType(), // A discovered 1-D array symbol carries its element count in // sym.ArrayLength (the browser reports the ELEMENT type as dt); // multi-dim/unsupported arrays arrive with null ArrayLength → scalar // (Phase 4c). IsArray: sym.ArrayLength is > 0, ArrayDim: sym.ArrayLength is > 0 ? (uint)sym.ArrayLength.Value : null, SecurityClass: sym.ReadOnly ? SecurityClassification.ViewOnly : SecurityClassification.Operate, IsHistorized: false, IsAlarm: false, WriteIdempotent: false)); } } catch (OperationCanceledException) { throw; } catch (Exception ex) { // Symbol-loader failure is non-fatal to discovery — pre-declared tags already // shipped. Log so operators can correlate the partial discovery. _logger.LogWarning(ex, "TwinCAT driver '{DriverInstanceId}' symbol browse failed for device " + "'{HostAddress}'; falling back to pre-declared tags only", _driverInstanceId, device.HostAddress); } } } } // ---- ISubscribable (native ADS notifications with poll fallback) ---- private readonly ConcurrentDictionary _nativeSubs = new(); private long _nextNativeSubId; private long _nextRegId; /// // Subscribe via native ADS notifications when TwinCATDriverOptions.UseNativeNotifications is // true, otherwise fall through to the shared PollGroupEngine. Native path registers one // notification per tag against the target's PLC runtime — the PLC pushes changes on its own // cycle so we skip the poll loop entirely. Each registration stores its REPLAYABLE INTENT on // the owning device so EnsureConnectedAsync can re-register it after a reconnect. public async Task SubscribeAsync( IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) { if (!_options.UseNativeNotifications) return _poll.Subscribe(fullReferences, publishingInterval); var id = Interlocked.Increment(ref _nextNativeSubId); var handle = new NativeSubscriptionHandle(id); var registrations = new List(fullReferences.Count); try { foreach (var reference in fullReferences) { if (!_resolver.TryResolve(reference, out var def)) continue; if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue; var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; var bitIndex = parsed?.BitIndex; // The callback closes over the stable (handle, reference) pair, so it survives a // client swap unchanged — replay re-registers the SAME intent onto the new client. var reg = new NativeRegistration( Interlocked.Increment(ref _nextRegId), device, reference, symbolName, def.DataType, bitIndex, publishingInterval, onChange: (_, value) => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot( value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))), // 05/STAB-16: when the handle goes dead (a failed replay) push a Bad snapshot so // subscribers see the tag go Bad instead of a frozen-Good-stale value. onUnavailable: () => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot( null, TwinCATStatusMapper.BadCommunicationError, null, DateTime.UtcNow)))); await RegisterNotificationAsync(device, reg, cancellationToken).ConfigureAwait(false); registrations.Add(reg); } } catch (Exception ex) { // On any registration failure, tear down everything we got so far + rethrow. Leaves // the subscription in a clean "never existed" state rather than a half-registered // state the caller has to clean up. _logger.LogWarning(ex, "TwinCAT driver '{DriverInstanceId}' native-notification registration failed; " + "tearing down {Count} partial registrations", _driverInstanceId, registrations.Count); foreach (var r in registrations) { try { r.Dispose(); } catch { } } throw; } _nativeSubs[id] = new NativeSubscription(handle, registrations); return handle; } /// /// Registers one native notification and records its replayable intent on the device — both /// under the device so the register can't race a /// concurrent reconnect-replay (which also runs under that gate). The intent is added to the /// device registry only AFTER the handle is live, so a replay triggered by this same /// EnsureConnected can't double-register the not-yet-stored tag. /// private async Task RegisterNotificationAsync(DeviceState device, NativeRegistration reg, CancellationToken ct) { await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false); try { var client = await EnsureConnectedUnderGateAsync(device, ct, timeoutOverride: null).ConfigureAwait(false); var newHandle = await client.AddNotificationAsync( reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval, _options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false); reg.SwapHandle(newHandle); // initial set — previous handle is null device.NativeRegistrations[reg.Id] = reg; } finally { device.ConnectGate.Release(); } } /// public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { if (handle is NativeSubscriptionHandle native && _nativeSubs.TryRemove(native.Id, out var sub)) { // NativeRegistration.Dispose removes itself from its device registry (so a later replay // won't touch it) and disposes its live handle. foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } } return Task.CompletedTask; } _poll.Unsubscribe(handle); return Task.CompletedTask; } private sealed record NativeSubscriptionHandle(long Id) : ISubscriptionHandle { /// public string DiagnosticId => $"twincat-native-sub-{Id}"; } private sealed record NativeSubscription( NativeSubscriptionHandle Handle, IReadOnlyList Registrations); /// /// One live native-notification registration: its replayable intent plus the current live /// . The handle is swapped (not re-created) when the /// device reconnects, so unsubscribe / teardown always dispose the latest one. The handle /// field is exchanged atomically so a reconnect-replay swap and an unsubscribe dispose can't /// corrupt each other on the common path. /// internal sealed class NativeRegistration : IDisposable { /// Globally-unique registration id (registry key on the device). public long Id { get; } /// The device this notification is registered against. public DeviceState Device { get; } /// Driver-side full reference the pushed value is reported under. public string Reference { get; } /// ADS symbol name (resolved from the symbol path). public string SymbolName { get; } /// Declared data type. public TwinCATDataType DataType { get; } /// Bit index for a BOOL-within-word symbol; null otherwise. public int? BitIndex { get; } /// Requested notification interval. public TimeSpan Interval { get; } /// Value-change callback (closes over the owning subscription handle + reference). public Action OnChange { get; } /// /// Bad-quality callback (05/STAB-16). Invoked when this registration's handle goes dead /// (a failed replay) so subscribers observe the tag go Bad instead of frozen-Good-stale — /// the shape has no quality channel. Closes over the same /// (handle, reference) pair as . /// public Action OnUnavailable { get; } private ITwinCATNotificationHandle? _handle; /// /// True while this registration holds a live notification handle. False after a failed /// replay marks it dead (05/STAB-16) — the probe-tick retry re-registers such handles. /// public bool HasLiveHandle => Volatile.Read(ref _handle) is not null; /// Initializes a new instance of the class. /// Globally-unique registration id (registry key on the device). /// The device this notification is registered against. /// Driver-side full reference the pushed value is reported under. /// ADS symbol name (resolved from the symbol path). /// Declared data type. /// Bit index for a BOOL-within-word symbol; null otherwise. /// Requested notification interval. /// Value-change callback (closes over the owning subscription handle + reference). /// Bad-quality callback fired when the handle goes dead (05/STAB-16). public NativeRegistration(long id, DeviceState device, string reference, string symbolName, TwinCATDataType dataType, int? bitIndex, TimeSpan interval, Action onChange, Action onUnavailable) { Id = id; Device = device; Reference = reference; SymbolName = symbolName; DataType = dataType; BitIndex = bitIndex; Interval = interval; OnChange = onChange; OnUnavailable = onUnavailable; } /// Installs a freshly-registered handle and returns the previous one (null on first set). /// The newly-registered notification handle. /// The previous handle, or null. public ITwinCATNotificationHandle? SwapHandle(ITwinCATNotificationHandle newHandle) => Interlocked.Exchange(ref _handle, newHandle); /// /// Atomically clears the live handle and returns the previous one (05/STAB-16). The caller /// disposes the returned (already-dead) handle best-effort. After this /// is false until a retry re-registers. /// /// The previous handle, or null. public ITwinCATNotificationHandle? MarkHandleDead() => Interlocked.Exchange(ref _handle, null); /// Removes this registration from its device and disposes its live handle. public void Dispose() { Device.NativeRegistrations.TryRemove(Id, out _); var h = Interlocked.Exchange(ref _handle, null); try { h?.Dispose(); } catch { /* best-effort */ } } } // ---- IHostConnectivityProbe ---- /// public IReadOnlyList GetHostStatuses() => [.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))]; private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct) { while (!ct.IsCancellationRequested) { var success = false; try { // Probe-initiated connects honor TwinCATProbeOptions.Timeout — distinct from // the driver-wide _options.Timeout used by reads/writes. // The probe bypasses the connect backoff — its own interval is the bounded recovery // cadence, and a successful probe connect resets the window for the data path. var client = await EnsureConnectedAsync(state, ct, _options.Probe.Timeout, bypassBackoff: true) .ConfigureAwait(false); success = await client.ProbeAsync(ct).ConfigureAwait(false); // A locally-connected client whose wire probe FAILS is wire-dead but port-open: // force a recycle so the next tick rebuilds + replays native notifications. Without // this the IsConnected fast-path would keep handing back the dead client forever // (the compounding bug — native pushes would never recover). if (!success) await RecycleClientAsync(state, ct).ConfigureAwait(false); else // 05/STAB-16: on a healthy tick, re-register any notifications whose replay // failed (a transient AddNotification blip) so they recover without a client swap. await RetryDeadRegistrationsAsync(state, ct).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch { // Probe threw — the connect-failure path clears the client only when the CONNECT // failed; a throw from ProbeAsync on an already-connected client would leave it in // place, so force a recycle to guarantee the next tick rebuilds + replays. A shutdown // racing this recycle cancels the gate wait (STAB-12) — exit the loop cleanly. try { await RecycleClientAsync(state, ct).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } } TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } } private void TransitionDeviceState(DeviceState state, HostState newState) { HostState old; lock (state.ProbeLock) { old = state.HostState; if (old == newState) return; state.HostState = newState; state.HostStateChangedUtc = DateTime.UtcNow; } _logger.LogInformation( "TwinCAT driver '{DriverInstanceId}' device '{HostAddress}' state: {OldState} → {NewState}", _driverInstanceId, state.Options.HostAddress, old, newState); OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState)); } // ---- IPerCallHostResolver ---- /// /// Documented sentinel returned by when neither the tag nor a /// fallback device is configured. Empty-string never matches an /// emitted by this driver (every real /// host is an ads://… URI), so it cleanly signals "unresolved" without colliding /// with a real host key. Used to be , which is a logical /// config-DB identifier — that collided with consumers who expected the resolver and the /// connectivity-status table to share keys. /// public const string UnresolvedHostSentinel = ""; /// /// Resolve a to the device's live host address /// () used to route reads/writes/subscriptions /// to the right connection. TODO(v3 WaveC): the live device host must come from the /// Device row's DeviceConfig (the deploy artifact will carry it); today we resolve /// the DeviceName against this driver's own list — /// first by , then by /// (callers that route by host, e.g. the Client /// CLI, pass the host string as the DeviceName). An empty DeviceName routes to the sole device /// on a single-device instance; an unmatched name leaves the def unrouted (→ BadNodeIdUnknown). /// /// The owning device's name (or host) from the raw-tag entry. /// The resolved device host address, or empty when unresolved. private string ResolveDeviceHost(string deviceName) { var devices = _options.Devices; if (string.IsNullOrEmpty(deviceName)) return devices.Count == 1 ? devices[0].HostAddress : ""; foreach (var d in devices) if (string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase)) return d.HostAddress; foreach (var d in devices) if (string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase)) return d.HostAddress; return ""; } /// /// Resolve a reference to the device host that keys its per-host resilience /// (bulkhead / circuit breaker). CONV-4: routes through /// so an equipment-tag reference (raw TagConfig /// JSON) resolves to its OWN authored device rather than the first-device fallback. The /// empty-host guard keeps an address-less equipment tag on the fallback rather than keying /// the . The resolver caches parses, so this adds no /// per-call cost after first use. /// /// The tag reference (authored name or equipment-tag JSON). /// The device host key. public string ResolveHost(string fullReference) { if (_resolver.TryResolve(fullReference, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress)) return def.DeviceHostAddress; // First device's HostAddress when one exists; otherwise the unresolved sentinel — // intentionally NOT DriverInstanceId, which is a config-DB key, not a host address. return _options.Devices.FirstOrDefault()?.HostAddress ?? UnresolvedHostSentinel; } /// /// Lazily connect a device's client, serialized per device by /// . Without the gate, a /// concurrent read / write / probe could each create + connect a separate client and /// leak all-but-one, or dispose a client another thread is mid-connect on. The S7 and /// AB-CIP drivers serialize device access the same way; single-connection-per-PLC is /// also what docs/v2/driver-specs.md recommends. /// private async Task EnsureConnectedAsync( DeviceState device, CancellationToken ct, TimeSpan? timeoutOverride = null, bool bypassBackoff = false) { // Fast path — already connected, no gate needed. if (device.Client is { IsConnected: true } fast) return fast; await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false); try { return await EnsureConnectedUnderGateAsync(device, ct, timeoutOverride, bypassBackoff).ConfigureAwait(false); } finally { device.ConnectGate.Release(); } } /// /// Thrown by the connect path when a per-device window is /// still open (05/STAB-8) — a fail-fast that costs no wire traffic. Read/write map it to /// BadCommunicationError via their generic catch, exactly as a real connect failure. /// private sealed class ConnectionThrottledException(string message) : Exception(message); /// /// The connect-or-reconnect core, assuming the caller already holds /// . When it installs a NEW client (a genuine /// reconnect, not a fast-path reuse) it replays every stored native-notification intent /// onto it — otherwise native ADS pushes would silently stop after a drop. Both /// the initial subscribe () and the probe loop call /// through here under the same gate, so a replay can never race a register. /// private async Task EnsureConnectedUnderGateAsync( DeviceState device, CancellationToken ct, TimeSpan? timeoutOverride, bool bypassBackoff = false) { // Re-check under the gate: another caller may have connected while we waited. if (device.Client is { IsConnected: true } c) return c; // 05/STAB-8: inside an open backoff window a data-path caller fails fast — no client // create, no wire connect. The probe loop bypasses this (its interval is the recovery // cadence) and drives the failure/success recording. if (!bypassBackoff && !device.Backoff.ShouldAttempt(DateTime.UtcNow)) throw new ConnectionThrottledException( $"TwinCAT connect to {device.Options.HostAddress} throttled — backoff window open after a recent connect failure."); // Discard a stale (created-but-disconnected) client before making a fresh one. if (device.Client is { IsConnected: false } stale) { stale.OnSymbolVersionChanged -= HandleSymbolVersionChanged; try { stale.Dispose(); } catch { /* best-effort */ } device.Client = null; } var client = _clientFactory.Create(); client.OnSymbolVersionChanged += HandleSymbolVersionChanged; // timeoutOverride lets the probe loop use TwinCATProbeOptions.Timeout for probe- // initiated connects rather than the driver-level _options.Timeout. Reads / writes // pass null and get the driver default. var effectiveTimeout = timeoutOverride ?? _options.Timeout; try { await client.ConnectAsync(device.ParsedAddress, effectiveTimeout, ct) .ConfigureAwait(false); _logger.LogInformation( "TwinCAT driver '{DriverInstanceId}' connected to {HostAddress}", _driverInstanceId, device.Options.HostAddress); } catch (Exception ex) { _logger.LogWarning(ex, "TwinCAT driver '{DriverInstanceId}' failed to connect to {HostAddress}", _driverInstanceId, device.Options.HostAddress); client.OnSymbolVersionChanged -= HandleSymbolVersionChanged; client.Dispose(); device.Backoff.RecordFailure(DateTime.UtcNow); // 05/STAB-8: open the backoff window throw; } device.Client = client; device.Backoff.RecordSuccess(); // 05/STAB-8: recovery resets the window immediately // Re-register every native notification onto the fresh client. Native ADS is push, so // (unlike the poll fallback) it cannot self-heal by re-reading — without this the // subscription is silently dead after a reconnect. await ReplayNativeRegistrationsAsync(device, client, ct).ConfigureAwait(false); return client; } /// /// Re-registers every stored for a device onto the given /// freshly-connected client and swaps in the new live handle (disposing the dead one from /// the previous client). Runs under . A single /// registration that fails to replay is logged and skipped — one bad symbol must not abort /// recovery of the rest. /// private async Task ReplayNativeRegistrationsAsync(DeviceState device, ITwinCATClient client, CancellationToken ct) { var intents = device.NativeRegistrations.Values.ToArray(); if (intents.Length == 0) return; var replayed = 0; foreach (var reg in intents) { try { var newHandle = await client.AddNotificationAsync( reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval, _options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false); var old = reg.SwapHandle(newHandle); try { old?.Dispose(); } catch { /* dead handle from the disposed client */ } // 05/STAB-17: an UnsubscribeAsync (gate-free) can race this replay — if it removed the // registration between the intents snapshot and this swap, the fresh handle is // ownerless. Retract it immediately so the ADS notification isn't leaked live. if (!device.NativeRegistrations.ContainsKey(reg.Id)) { var orphan = reg.MarkHandleDead(); try { orphan?.Dispose(); } catch { /* best-effort */ } continue; } replayed++; } catch (Exception ex) { _logger.LogWarning(ex, "TwinCAT driver '{DriverInstanceId}' failed to replay native notification " + "'{Symbol}' on device '{HostAddress}' after reconnect", _driverInstanceId, reg.SymbolName, device.Options.HostAddress); // 05/STAB-16: the intent has no live handle now — mark it dead so the probe-tick // retry re-registers it, surface Bad quality to subscribers (the OnChange shape has // no quality channel), and degrade health preserving the last successful read. var dead = reg.MarkHandleDead(); try { dead?.Dispose(); } catch { /* already-dead handle from the disposed client */ } try { reg.OnUnavailable(); } catch { /* never let a subscriber callback abort recovery of the rest */ } if (_health.State != DriverState.Faulted) _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, $"native-notification replay failed for '{reg.SymbolName}' on {device.Options.HostAddress}"); } } _logger.LogInformation( "TwinCAT driver '{DriverInstanceId}' re-registered {Replayed}/{Total} native notifications " + "on device '{HostAddress}' after reconnect", _driverInstanceId, replayed, intents.Length, device.Options.HostAddress); } /// /// 05/STAB-16: re-registers every dead (failed-replay) onto /// the device's currently-connected client. Runs under /// (shared with the replay so it can't race a register/unsubscribe). Called from the probe /// loop after a successful probe — a bounded retry cadence (the probe interval) with no extra /// timers. A registration that fails again just stays dead for the next tick. Recovery of the /// value itself arrives via ADS's initial-notification push on register, exactly as the /// initial subscribe relies on. /// private async Task RetryDeadRegistrationsAsync(DeviceState device, CancellationToken ct) { if (device.NativeRegistrations.IsEmpty) return; await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false); try { if (device.Client is not { IsConnected: true } client) return; var recovered = 0; foreach (var reg in device.NativeRegistrations.Values) { if (reg.HasLiveHandle) continue; try { var newHandle = await client.AddNotificationAsync( reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval, _options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false); reg.SwapHandle(newHandle); // STAB-17 ownership re-check — see ReplayNativeRegistrationsAsync. if (!device.NativeRegistrations.ContainsKey(reg.Id)) { var orphan = reg.MarkHandleDead(); try { orphan?.Dispose(); } catch { /* best-effort */ } continue; } recovered++; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { _logger.LogDebug(ex, "TwinCAT driver '{DriverInstanceId}' probe-tick retry of dead notification " + "'{Symbol}' on device '{HostAddress}' failed; will retry next tick", _driverInstanceId, reg.SymbolName, device.Options.HostAddress); } } if (recovered > 0) _logger.LogInformation( "TwinCAT driver '{DriverInstanceId}' recovered {Recovered} dead native notification(s) " + "on device '{HostAddress}' via probe-tick retry", _driverInstanceId, recovered, device.Options.HostAddress); } finally { device.ConnectGate.Release(); } } /// /// Disposes + nulls a device's client under so the /// next rebuilds it (and replays notifications). Called /// by the probe loop when a wire-level probe fails on a locally-"connected" client — the /// fast-path keys on the AMS-port state, not wire /// liveness, so without this forced recycle a wire-dead-but-port-open client would keep /// being reused and native pushes would never recover. /// private async Task RecycleClientAsync(DeviceState device, CancellationToken ct) { // 05/STAB-12 (compounded part): honor the probe token so a connect wedged under the gate // (TwinCAT's blocking SDK Connect — the un-fixed root, carried) can't hang the probe loop // beyond cancellation. Was CancellationToken.None. await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false); try { if (device.Client is { } c) { c.OnSymbolVersionChanged -= HandleSymbolVersionChanged; try { c.Dispose(); } catch { /* best-effort */ } device.Client = null; } } finally { device.ConnectGate.Release(); } } /// /// Routes a wire-detected ADS symbol-version-changed (DeviceSymbolVersionInvalid 1809 / /// 0x0711) to Core as an invocation. /// A PLC re-download invalidates every symbol + notification handle, so the address /// space must be rebuilt — this is the documented TwinCAT failure mode, not a transient /// connection error. /// private void HandleSymbolVersionChanged(object? sender, EventArgs e) => OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs( "TwinCAT symbol-version-changed (DeviceSymbolVersionInvalid 0x0711) — PLC program re-downloaded", ScopeHint: "TwinCAT")); /// /// Synchronous teardown — no await, no captured sync context. The OPC UA stack /// thread can call ; routing through DisposeAsync().GetResult() /// can deadlock on a single-threaded sync context (see docs/v2/driver-stability.md). /// The operations here are all genuinely synchronous — /// cancel tokens, wait on task handles with a hard timeout, dispose clients — so a /// synchronous path does the right thing without re-entering the scheduler. /// /// Synchronously disposes driver resources without awaiting async operations. public void Dispose() { // Dispose native subscriptions first — handle disposal is sync. foreach (var sub in _nativeSubs.Values) foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } } _nativeSubs.Clear(); // PollGroupEngine.DisposeAsync awaits loop tasks; we drive that synchronously here // (bounded wait — same 5s ceiling DisposeAsync uses internally) using Wait() on the // returned ValueTask so no sync-context capture happens. try { _poll.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); } catch { } foreach (var state in _devices.Values) { try { state.ProbeCts?.Cancel(); } catch { } if (state.ProbeTask is Task pt) { try { pt.Wait(TimeSpan.FromSeconds(2)); } catch { /* probe-cancel races are expected */ } } state.ProbeCts?.Dispose(); state.ProbeCts = null; state.DisposeClient(); state.DisposeGate(); } _devices.Clear(); _tagsByRawPath.Clear(); _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry // Dispose + clear the per-parent-word RMW gates (mirrors ShutdownAsync) so the // SemaphoreSlim instances aren't orphaned on disposal. foreach (var sem in _bitRmwLocks.Values) sem.Dispose(); _bitRmwLocks.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } /// Asynchronously disposes driver resources. /// Completion task. public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); internal sealed class DeviceState(TwinCATAmsAddress parsedAddress, TwinCATDeviceOptions options) { /// Gets the parsed AMS address for the device. public TwinCATAmsAddress ParsedAddress { get; } = parsedAddress; /// Gets the device configuration options. public TwinCATDeviceOptions Options { get; } = options; /// Gets or sets the active ADS client for this device. public ITwinCATClient? Client { get; set; } /// Serializes connect / reconnect so concurrent callers never race a client /// create-or-dispose for this device. public SemaphoreSlim ConnectGate { get; } = new(1, 1); /// /// Per-device connect-attempt throttle (05/STAB-8). A dead device fails fast inside the /// backoff window instead of paying a full connect on every data call; the probe loop /// bypasses it and a successful connect resets it. Consulted + mutated only under /// , satisfying 's /// caller-synchronized contract. /// public ConnectionBackoff Backoff { get; } = new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30)); /// /// Live native-notification registrations against this device, keyed by registration id. /// Holds the replayable intent (symbol / type / bit / interval / callback), not just /// an opaque handle, so that when swaps in /// a fresh client after a drop it can re-register every notification onto it. /// Populated by SubscribeAsync, drained by UnsubscribeAsync / teardown. /// public ConcurrentDictionary NativeRegistrations { get; } = new(); /// Gets the lock object for synchronizing host state transitions. public object ProbeLock { get; } = new(); /// Gets or sets the current host connectivity state. public HostState HostState { get; set; } = HostState.Unknown; /// Gets or sets the UTC timestamp of the last host state change. public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow; /// Gets or sets the cancellation token source for the probe loop. public CancellationTokenSource? ProbeCts { get; set; } /// The running probe-loop task — awaited by /// so the loop cannot touch a disposed client. public Task? ProbeTask { get; set; } /// Disposes the active ADS client if any. public void DisposeClient() { Client?.Dispose(); Client = null; } /// Disposes the connection gate semaphore. public void DisposeGate() => ConnectGate.Dispose(); } }