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