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 (Driver.TwinCAT-001). // 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 // (Driver.TwinCAT-009). private readonly ConcurrentDictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); 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; _poll = new PollGroupEngine( reader: ReadAsync, onChange: (handle, tagRef, snapshot) => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot))); } /// Gets the unique driver instance identifier. public string DriverInstanceId => _driverInstanceId; /// Gets the driver type name. public string DriverType => "TwinCAT"; /// Initializes the driver with configuration and establishes device connections. /// JSON configuration string for the driver. /// Cancellation token. /// Completion task. public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { _health = new DriverHealth(DriverState.Initializing, null, null); try { // Apply the supplied config generation (Driver.TwinCAT-001). 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.Tags.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); } foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag; 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 (Driver.TwinCAT-009). 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; } /// Reinitializes the driver by shutting down and reinitializing with new configuration. /// JSON configuration string for the driver. /// Cancellation token. /// Completion task. public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { await ShutdownAsync(cancellationToken).ConfigureAwait(false); await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); } /// Shuts down the driver and releases all device connections and subscriptions. /// Cancellation token. /// Completion task. 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. (Driver.TwinCAT-009: ShutdownAsync previously // cancelled ProbeCts but did not await the task before calling DisposeClient.) 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(); _tagsByName.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } /// Gets the current driver health status. /// Driver health information. public DriverHealth GetHealth() => _health; /// /// Estimated bytes attributable to this driver instance (Driver.TwinCAT-012). /// 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() => (_tagsByName.Count * 256L) + (_nativeSubs.Count * 512L); /// /// No flushable cache exists in this driver — the symbol table is streamed fresh on /// every call. This is a no-op but is deliberately present /// so Core's cache-budget enforcement sees a compliant Tier-A driver. /// /// Cancellation token. /// Completion task. 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 ---- /// Reads values for the specified tag references from ADS devices. /// The full tag references to read. /// Cancellation token. /// Data value snapshots for each reference. 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 (!_tagsByName.TryGetValue(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; var (value, status) = await client.ReadValueAsync( symbolName, def.DataType, parsed?.BitIndex, 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 ---- /// Writes values to the specified tags on ADS devices. /// The write requests to execute. /// Cancellation token. /// Write results for each request. 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 (!_tagsByName.TryGetValue(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); 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 ---- /// Discovers devices and tags from ADS configuration and optionally controller symbols. /// Address space builder for adding discovered nodes. /// Cancellation token. /// Completion task. 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); // Pre-declared tags — always emitted as the authoritative config path. var tagsForDevice = _options.Tags.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(), IsArray: false, ArrayDim: 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(), IsArray: false, ArrayDim: 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; /// /// Subscribe via native ADS notifications when /// is true, otherwise fall through to the shared . /// Native path registers one per tag against the /// target's PLC runtime — the PLC pushes changes on its own cycle so we skip the poll /// loop entirely. Unsub path disposes the handles. /// /// The full tag references to subscribe to. /// The publishing interval for updates. /// Cancellation token. /// Subscription handle for managing the subscription. 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); var now = DateTime.UtcNow; try { foreach (var reference in fullReferences) { if (!_tagsByName.TryGetValue(reference, out var def)) continue; if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue; var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath); var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath; var bitIndex = parsed?.BitIndex; var reg = await client.AddNotificationAsync( symbolName, def.DataType, bitIndex, publishingInterval, _options.NotificationMaxDelayMs, (_, value) => OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot( value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))), 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; } /// Unsubscribes from a native or poll-based subscription. /// The subscription handle to unsubscribe. /// Cancellation token. /// Completion task. public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { if (handle is NativeSubscriptionHandle native && _nativeSubs.TryRemove(native.Id, out var sub)) { 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 { /// Gets the diagnostic identifier for the subscription. public string DiagnosticId => $"twincat-native-sub-{Id}"; } private sealed record NativeSubscription( NativeSubscriptionHandle Handle, IReadOnlyList Registrations); // ---- IHostConnectivityProbe ---- /// Gets the connectivity status for all configured devices. /// List of host connectivity statuses. 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 (Driver.TwinCAT-014). var client = await EnsureConnectedAsync(state, ct, _options.Probe.Timeout) .ConfigureAwait(false); success = await client.ProbeAsync(ct).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch { // Probe failure — EnsureConnectedAsync's connect-failure path already disposed // + cleared the client, so next tick will reconnect. } 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 (Driver.TwinCAT-006). /// public const string UnresolvedHostSentinel = ""; /// Resolves the device host address for the specified tag reference. /// The full tag reference. /// The host address or if not found. public string ResolveHost(string fullReference) { if (_tagsByName.TryGetValue(fullReference, out var def)) 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 // (Driver.TwinCAT-006). return _options.Devices.FirstOrDefault()?.HostAddress ?? UnresolvedHostSentinel; } /// /// Lazily connect a device's client, serialized per device by /// (Driver.TwinCAT-007). 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) { // Fast path — already connected, no gate needed. if (device.Client is { IsConnected: true } fast) return fast; await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false); try { // Re-check under the gate: another caller may have connected while we waited. if (device.Client is { IsConnected: true } c) return c; // Discard a stale (created-but-disconnected) client before making a fresh one. if (device.Client is { IsConnected: false } stale) { 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 // (Driver.TwinCAT-014). 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(); throw; } device.Client = client; return client; } finally { device.ConnectGate.Release(); } } /// /// Routes a wire-detected ADS symbol-version-changed (DeviceSymbolVersionInvalid 1809 / /// 0x0711) to Core as an invocation (Driver.TwinCAT-013). /// 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 (Driver.TwinCAT-015, /// 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(); _tagsByName.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 (Driver.TwinCAT-007). public SemaphoreSlim ConnectGate { get; } = new(1, 1); /// 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 (Driver.TwinCAT-009). 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(); } }