a53be2af65
- Rename TwinCATEquipmentTagParser -> TwinCATTagDefinitionFactory; TryParse ->
FromTagConfig(tagConfig, rawPath, out def). Drop leading-'{' heuristic + the
deviceHostAddress key; def.Name = rawPath. Keep strict-enum + Inspect; add a
Structure guard (sole tag path now) + inverse ToTagConfig.
- Options: Tags -> RawTags (RawTagEntry); keep Devices + protocol fields.
- Driver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
_options.RawTags via FromTagConfig, threading WriteIdempotent + DeviceName.
Multi-device: ResolveDeviceHost matches entry.DeviceName against options.Devices
(by name, then host) -> def.DeviceHostAddress (TODO(v3 WaveC): live host from the
Device row's DeviceConfig). DiscoverAsync now emits from the table.
- Factory: retire pre-declared tags[] DTO path; bind rawTags; keep Devices.
- Cli: BuildOptions serialises typed defs -> RawTagEntry via ToTagConfig.
- Tests: TwinCATRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
FromTagConfig; equipment/resolve-host/array tests delivered via RawTags.
Gate: Contracts + Driver build green; Driver.TwinCAT.Tests 192 pass, Cli.Tests 70 pass.
1173 lines
61 KiB
C#
1173 lines
61 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// TwinCAT ADS driver — talks to Beckhoff PLC runtimes (TC2 + TC3) via AMS / ADS. PR 1 ships
|
|
/// the <see cref="IDriver"/> skeleton; read / write / discover / subscribe / probe / host-
|
|
/// resolver land in PRs 2 and 3.
|
|
/// </summary>
|
|
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<TwinCATDriver> _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<string, DeviceState> _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<string, TwinCATTagDefinition> _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<string, SemaphoreSlim> _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<TwinCATTagDefinition> _resolver;
|
|
|
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
|
|
|
/// <summary>Occurs when a subscribed tag value changes.</summary>
|
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
|
/// <summary>Occurs when a device host connectivity status changes.</summary>
|
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
|
/// <summary>Occurs when the Galaxy object hierarchy or TwinCAT symbol table is rediscovered.</summary>
|
|
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="TwinCATDriver"/> class.</summary>
|
|
/// <param name="options">Driver configuration options.</param>
|
|
/// <param name="driverInstanceId">Unique driver instance identifier.</param>
|
|
/// <param name="clientFactory">Optional ADS client factory; defaults to <see cref="AdsTwinCATClientFactory"/>.</param>
|
|
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{TwinCATDriver}"/>.</param>
|
|
public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId,
|
|
ITwinCATClientFactory? clientFactory = null,
|
|
ILogger<TwinCATDriver>? logger = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
_options = options;
|
|
_driverInstanceId = driverInstanceId;
|
|
_clientFactory = clientFactory ?? new AdsTwinCATClientFactory();
|
|
_logger = logger ?? NullLogger<TwinCATDriver>.Instance;
|
|
_resolver = new EquipmentTagRefResolver<TwinCATTagDefinition>(
|
|
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);
|
|
}
|
|
|
|
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
|
|
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
|
|
|
/// <summary>
|
|
/// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and
|
|
/// degrades to <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>.
|
|
/// Never downgrades a <see cref="DriverState.Faulted"/> state (a stronger, config-level signal).
|
|
/// </summary>
|
|
/// <param name="ex">The exception caught by the poll engine.</param>
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string DriverInstanceId => _driverInstanceId;
|
|
/// <inheritdoc />
|
|
public string DriverType => "TwinCAT";
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
|
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public DriverHealth GetHealth() => _health;
|
|
|
|
/// <inheritdoc />
|
|
// 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);
|
|
|
|
/// <inheritdoc />
|
|
// 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;
|
|
|
|
/// <summary>Gets the count of configured devices.</summary>
|
|
internal int DeviceCount => _devices.Count;
|
|
/// <summary>Gets the device state for the specified host address.</summary>
|
|
/// <param name="hostAddress">The ADS host address.</param>
|
|
/// <returns>Device state or null if not found.</returns>
|
|
internal DeviceState? GetDeviceState(string hostAddress) =>
|
|
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
|
|
|
// ---- IReadable ----
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> 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 ----
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
|
IReadOnlyList<WriteRequest> 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 ----
|
|
|
|
/// <inheritdoc />
|
|
// 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;
|
|
|
|
/// <summary>ADS symbol upload is real device enumeration — config-gated on
|
|
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
|
|
public bool SupportsOnlineDiscovery => true;
|
|
|
|
/// <inheritdoc />
|
|
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<long, NativeSubscription> _nativeSubs = new();
|
|
private long _nextNativeSubId;
|
|
private long _nextRegId;
|
|
|
|
/// <inheritdoc />
|
|
// 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<ISubscriptionHandle> SubscribeAsync(
|
|
IReadOnlyList<string> 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<NativeRegistration>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers one native notification and records its replayable intent on the device — both
|
|
/// under the device <see cref="DeviceState.ConnectGate"/> 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
|
|
/// <c>EnsureConnected</c> can't double-register the not-yet-stored tag.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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
|
|
{
|
|
/// <inheritdoc />
|
|
public string DiagnosticId => $"twincat-native-sub-{Id}";
|
|
}
|
|
|
|
private sealed record NativeSubscription(
|
|
NativeSubscriptionHandle Handle,
|
|
IReadOnlyList<NativeRegistration> Registrations);
|
|
|
|
/// <summary>
|
|
/// One live native-notification registration: its replayable intent plus the current live
|
|
/// <see cref="ITwinCATNotificationHandle"/>. 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.
|
|
/// </summary>
|
|
internal sealed class NativeRegistration : IDisposable
|
|
{
|
|
/// <summary>Globally-unique registration id (registry key on the device).</summary>
|
|
public long Id { get; }
|
|
/// <summary>The device this notification is registered against.</summary>
|
|
public DeviceState Device { get; }
|
|
/// <summary>Driver-side full reference the pushed value is reported under.</summary>
|
|
public string Reference { get; }
|
|
/// <summary>ADS symbol name (resolved from the symbol path).</summary>
|
|
public string SymbolName { get; }
|
|
/// <summary>Declared data type.</summary>
|
|
public TwinCATDataType DataType { get; }
|
|
/// <summary>Bit index for a BOOL-within-word symbol; null otherwise.</summary>
|
|
public int? BitIndex { get; }
|
|
/// <summary>Requested notification interval.</summary>
|
|
public TimeSpan Interval { get; }
|
|
/// <summary>Value-change callback (closes over the owning subscription handle + reference).</summary>
|
|
public Action<string, object?> OnChange { get; }
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="OnChange"/> shape has no quality channel. Closes over the same
|
|
/// (handle, reference) pair as <see cref="OnChange"/>.
|
|
/// </summary>
|
|
public Action OnUnavailable { get; }
|
|
|
|
private ITwinCATNotificationHandle? _handle;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public bool HasLiveHandle => Volatile.Read(ref _handle) is not null;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="NativeRegistration"/> class.</summary>
|
|
/// <param name="id">Globally-unique registration id (registry key on the device).</param>
|
|
/// <param name="device">The device this notification is registered against.</param>
|
|
/// <param name="reference">Driver-side full reference the pushed value is reported under.</param>
|
|
/// <param name="symbolName">ADS symbol name (resolved from the symbol path).</param>
|
|
/// <param name="dataType">Declared data type.</param>
|
|
/// <param name="bitIndex">Bit index for a BOOL-within-word symbol; null otherwise.</param>
|
|
/// <param name="interval">Requested notification interval.</param>
|
|
/// <param name="onChange">Value-change callback (closes over the owning subscription handle + reference).</param>
|
|
/// <param name="onUnavailable">Bad-quality callback fired when the handle goes dead (05/STAB-16).</param>
|
|
public NativeRegistration(long id, DeviceState device, string reference, string symbolName,
|
|
TwinCATDataType dataType, int? bitIndex, TimeSpan interval, Action<string, object?> onChange,
|
|
Action onUnavailable)
|
|
{
|
|
Id = id;
|
|
Device = device;
|
|
Reference = reference;
|
|
SymbolName = symbolName;
|
|
DataType = dataType;
|
|
BitIndex = bitIndex;
|
|
Interval = interval;
|
|
OnChange = onChange;
|
|
OnUnavailable = onUnavailable;
|
|
}
|
|
|
|
/// <summary>Installs a freshly-registered handle and returns the previous one (null on first set).</summary>
|
|
/// <param name="newHandle">The newly-registered notification handle.</param>
|
|
/// <returns>The previous handle, or null.</returns>
|
|
public ITwinCATNotificationHandle? SwapHandle(ITwinCATNotificationHandle newHandle) =>
|
|
Interlocked.Exchange(ref _handle, newHandle);
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="HasLiveHandle"/> is false until a retry re-registers.
|
|
/// </summary>
|
|
/// <returns>The previous handle, or null.</returns>
|
|
public ITwinCATNotificationHandle? MarkHandleDead() =>
|
|
Interlocked.Exchange(ref _handle, null);
|
|
|
|
/// <summary>Removes this registration from its device and disposes its live handle.</summary>
|
|
public void Dispose()
|
|
{
|
|
Device.NativeRegistrations.TryRemove(Id, out _);
|
|
var h = Interlocked.Exchange(ref _handle, null);
|
|
try { h?.Dispose(); } catch { /* best-effort */ }
|
|
}
|
|
}
|
|
|
|
// ---- IHostConnectivityProbe ----
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<HostConnectivityStatus> 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 ----
|
|
|
|
/// <summary>
|
|
/// Documented sentinel returned by <see cref="ResolveHost"/> when neither the tag nor a
|
|
/// fallback device is configured. Empty-string never matches an
|
|
/// <see cref="HostConnectivityStatus.HostName"/> emitted by this driver (every real
|
|
/// host is an <c>ads://…</c> URI), so it cleanly signals "unresolved" without colliding
|
|
/// with a real host key. Used to be <see cref="DriverInstanceId"/>, which is a logical
|
|
/// config-DB identifier — that collided with consumers who expected the resolver and the
|
|
/// connectivity-status table to share keys.
|
|
/// </summary>
|
|
public const string UnresolvedHostSentinel = "";
|
|
|
|
/// <summary>
|
|
/// Resolve a <see cref="RawTagEntry.DeviceName"/> to the device's live host address
|
|
/// (<see cref="TwinCATTagDefinition.DeviceHostAddress"/>) used to route reads/writes/subscriptions
|
|
/// to the right connection. <b>TODO(v3 WaveC):</b> the live device host must come from the
|
|
/// <c>Device</c> row's <c>DeviceConfig</c> (the deploy artifact will carry it); today we resolve
|
|
/// the DeviceName against this driver's own <see cref="TwinCATDriverOptions.Devices"/> list —
|
|
/// first by <see cref="TwinCATDeviceOptions.DeviceName"/>, then by
|
|
/// <see cref="TwinCATDeviceOptions.HostAddress"/> (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).
|
|
/// </summary>
|
|
/// <param name="deviceName">The owning device's name (or host) from the raw-tag entry.</param>
|
|
/// <returns>The resolved device host address, or empty when unresolved.</returns>
|
|
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 "";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve a reference to the device host that keys its per-host resilience
|
|
/// (bulkhead / circuit breaker). CONV-4: routes through
|
|
/// <see cref="EquipmentTagRefResolver{TDef}"/> 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 <see cref="UnresolvedHostSentinel"/>. The resolver caches parses, so this adds no
|
|
/// per-call cost after first use.
|
|
/// </summary>
|
|
/// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
|
|
/// <returns>The device host key.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lazily connect a device's client, serialized per device by
|
|
/// <see cref="DeviceState.ConnectGate"/>. 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.
|
|
/// </summary>
|
|
private async Task<ITwinCATClient> 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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Thrown by the connect path when a per-device <see cref="ConnectionBackoff"/> window is
|
|
/// still open (05/STAB-8) — a fail-fast that costs no wire traffic. Read/write map it to
|
|
/// <c>BadCommunicationError</c> via their generic catch, exactly as a real connect failure.
|
|
/// </summary>
|
|
private sealed class ConnectionThrottledException(string message) : Exception(message);
|
|
|
|
/// <summary>
|
|
/// The connect-or-reconnect core, assuming the caller already holds
|
|
/// <see cref="DeviceState.ConnectGate"/>. 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 (<see cref="RegisterNotificationAsync"/>) and the probe loop call
|
|
/// through here under the same gate, so a replay can never race a register.
|
|
/// </summary>
|
|
private async Task<ITwinCATClient> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-registers every stored <see cref="NativeRegistration"/> 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 <see cref="DeviceState.ConnectGate"/>. A single
|
|
/// registration that fails to replay is logged and skipped — one bad symbol must not abort
|
|
/// recovery of the rest.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 05/STAB-16: re-registers every dead (failed-replay) <see cref="NativeRegistration"/> onto
|
|
/// the device's currently-connected client. Runs under <see cref="DeviceState.ConnectGate"/>
|
|
/// (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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposes + nulls a device's client under <see cref="DeviceState.ConnectGate"/> so the
|
|
/// next <see cref="EnsureConnectedAsync"/> rebuilds it (and replays notifications). Called
|
|
/// by the probe loop when a wire-level probe fails on a locally-"connected" client — the
|
|
/// <see cref="ITwinCATClient.IsConnected"/> 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Routes a wire-detected ADS symbol-version-changed (DeviceSymbolVersionInvalid 1809 /
|
|
/// 0x0711) to Core as an <see cref="IRediscoverable"/> 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.
|
|
/// </summary>
|
|
private void HandleSymbolVersionChanged(object? sender, EventArgs e) =>
|
|
OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(
|
|
"TwinCAT symbol-version-changed (DeviceSymbolVersionInvalid 0x0711) — PLC program re-downloaded",
|
|
ScopeHint: "TwinCAT"));
|
|
|
|
/// <summary>
|
|
/// Synchronous teardown — no <c>await</c>, no captured sync context. The OPC UA stack
|
|
/// thread can call <see cref="Dispose"/>; routing through <c>DisposeAsync().GetResult()</c>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <summary>Synchronously disposes driver resources without awaiting async operations.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Asynchronously disposes driver resources.</summary>
|
|
/// <returns>Completion task.</returns>
|
|
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
|
|
|
internal sealed class DeviceState(TwinCATAmsAddress parsedAddress, TwinCATDeviceOptions options)
|
|
{
|
|
/// <summary>Gets the parsed AMS address for the device.</summary>
|
|
public TwinCATAmsAddress ParsedAddress { get; } = parsedAddress;
|
|
/// <summary>Gets the device configuration options.</summary>
|
|
public TwinCATDeviceOptions Options { get; } = options;
|
|
/// <summary>Gets or sets the active ADS client for this device.</summary>
|
|
public ITwinCATClient? Client { get; set; }
|
|
|
|
/// <summary>Serializes connect / reconnect so concurrent callers never race a client
|
|
/// create-or-dispose for this device.</summary>
|
|
public SemaphoreSlim ConnectGate { get; } = new(1, 1);
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="ConnectGate"/>, satisfying <see cref="ConnectionBackoff"/>'s
|
|
/// caller-synchronized contract.
|
|
/// </summary>
|
|
public ConnectionBackoff Backoff { get; } = new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
|
|
|
|
/// <summary>
|
|
/// Live native-notification registrations against this device, keyed by registration id.
|
|
/// Holds the <b>replayable intent</b> (symbol / type / bit / interval / callback), not just
|
|
/// an opaque handle, so that when <see cref="TwinCATDriver.EnsureConnectedAsync"/> swaps in
|
|
/// a fresh client after a drop it can re-register every notification onto it.
|
|
/// Populated by <c>SubscribeAsync</c>, drained by <c>UnsubscribeAsync</c> / teardown.
|
|
/// </summary>
|
|
public ConcurrentDictionary<long, NativeRegistration> NativeRegistrations { get; } = new();
|
|
|
|
/// <summary>Gets the lock object for synchronizing host state transitions.</summary>
|
|
public object ProbeLock { get; } = new();
|
|
/// <summary>Gets or sets the current host connectivity state.</summary>
|
|
public HostState HostState { get; set; } = HostState.Unknown;
|
|
/// <summary>Gets or sets the UTC timestamp of the last host state change.</summary>
|
|
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
|
/// <summary>Gets or sets the cancellation token source for the probe loop.</summary>
|
|
public CancellationTokenSource? ProbeCts { get; set; }
|
|
/// <summary>The running probe-loop task — awaited by <see cref="TwinCATDriver.ShutdownAsync"/>
|
|
/// so the loop cannot touch a disposed client.</summary>
|
|
public Task? ProbeTask { get; set; }
|
|
|
|
/// <summary>Disposes the active ADS client if any.</summary>
|
|
public void DisposeClient()
|
|
{
|
|
Client?.Dispose();
|
|
Client = null;
|
|
}
|
|
|
|
/// <summary>Disposes the connection gate semaphore.</summary>
|
|
public void DisposeGate() => ConnectGate.Dispose();
|
|
}
|
|
}
|