docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
This commit is contained in:
Joseph Doherty
2026-07-07 12:38:39 -04:00
parent 384dbd7d36
commit 9cad9ed0fc
375 changed files with 1899 additions and 2493 deletions
@@ -22,10 +22,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// </remarks>
internal sealed class AdsTwinCATClient : ITwinCATClient
{
// Bounded so a slow downstream consumer cannot back the AMS router thread up — the
// router thread enqueues and returns immediately (Driver.TwinCAT-008). 50k matches the
// Galaxy EventPump default; ~500 notifications/connection is the ADS ceiling so this is
// generous headroom against bursty change storms.
private const int NotificationQueueCapacity = 50_000;
private readonly AdsClient _client = new();
@@ -57,9 +53,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
private readonly record struct PendingNotification(NotificationRegistration Registration, object? Value);
/// <summary>
/// Gets a value indicating whether the client is connected.
/// </summary>
/// <inheritdoc />
public bool IsConnected => _client.IsConnected;
/// <summary>
@@ -75,13 +69,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return TwinCATStatusMapper.MapAdsError(adsError);
}
/// <summary>
/// Connects to the specified ADS target asynchronously.
/// </summary>
/// <param name="address">The AMS address to connect to.</param>
/// <param name="timeout">The connection timeout.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the asynchronous connection operation.</returns>
/// <inheritdoc />
public Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken)
{
if (_client.IsConnected) return Task.CompletedTask;
@@ -91,16 +79,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return Task.CompletedTask;
}
/// <summary>
/// Reads a value from the specified symbol path asynchronously.
/// </summary>
/// <param name="symbolPath">The ADS symbol path to read from.</param>
/// <param name="type">The TwinCAT data type.</param>
/// <param name="bitIndex">Optional bit index for BOOL values within larger containers.</param>
/// <param name="arrayCount">When non-null, read a 1-D array of this many <paramref name="type"/>
/// elements; the boxed value is the element-typed CLR array (e.g. <c>int[]</c>). Phase 4c.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A tuple containing the value and OPC UA status code.</returns>
/// <inheritdoc />
public async Task<(object? value, uint status)> ReadValueAsync(
string symbolPath,
TwinCATDataType type,
@@ -170,17 +149,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
: symbolPath;
}
/// <summary>
/// Writes a value to the specified symbol path asynchronously.
/// </summary>
/// <param name="symbolPath">The ADS symbol path to write to.</param>
/// <param name="type">The TwinCAT data type.</param>
/// <param name="bitIndex">Optional bit index for BOOL values. BOOL-within-word writes are handled
/// upstream by <see cref="TwinCATDriver.WriteAsync"/> as a parent-word read-modify-write, so a
/// bit index does not reach this method on the write path.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The OPC UA status code of the write operation.</returns>
/// <inheritdoc />
public async Task<uint> WriteValueAsync(
string symbolPath,
TwinCATDataType type,
@@ -203,11 +172,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
}
/// <summary>
/// Probes the connection to verify the ADS target is reachable.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>True if the probe succeeds; otherwise false.</returns>
/// <inheritdoc />
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
{
try
@@ -221,17 +186,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
}
/// <summary>
/// Adds a notification (subscription) for changes to the specified symbol.
/// </summary>
/// <param name="symbolPath">The ADS symbol path to monitor.</param>
/// <param name="type">The TwinCAT data type.</param>
/// <param name="bitIndex">Optional bit index for BOOL values.</param>
/// <param name="cycleTime">The minimum cycle time between notifications.</param>
/// <param name="maxDelayMs">The maximum delay before delivering a batched notification.</param>
/// <param name="onChange">The callback to invoke when the value changes.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A handle to manage the notification subscription.</returns>
/// <inheritdoc />
public async Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath,
TwinCATDataType type,
@@ -242,13 +197,6 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
CancellationToken cancellationToken)
{
var clrType = MapToClrType(type);
// NotificationSettings takes cycle + max-delay in milliseconds (Beckhoff InfoSys
// tcadsnetref/7313319051 — "The unit is 1ms"). AdsTransMode.OnChange fires when
// the value differs; OnCycle fires every cycle. OnChange is the right default for
// OPC UA data-change semantics — the PLC already has the best view of "has this
// changed" so we let it decide. maxDelayMs > 0 lets TwinCAT batch notifications up
// to that delay before pushing them — exposed via TwinCATDriverOptions
// (Driver.TwinCAT-014).
var cycleMs = (int)Math.Max(1, cycleTime.TotalMilliseconds);
var settings = new NotificationSettings(AdsTransMode.OnChange, cycleMs, Math.Max(0, maxDelayMs));
@@ -270,8 +218,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// <summary>
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap bit-extraction
/// decode then enqueues — no driver logic, no consumer callbacks — so a slow consumer
/// can never stall ADS notification delivery for the rest of the process
/// (Driver.TwinCAT-008). Drops the notification (DropWrite) if the queue is saturated.
/// can never stall ADS notification delivery for the rest of the process.
/// Drops the notification (DropWrite) if the queue is saturated.
/// </summary>
private void OnAdsNotificationEx(object? sender, AdsNotificationExEventArgs args)
{
@@ -311,11 +259,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
catch { /* best-effort tear-down; target may already be gone */ }
}
/// <summary>
/// Browses all available symbols on the connected ADS target asynchronously.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An async enumerable of discovered symbols.</returns>
/// <inheritdoc />
public async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -341,11 +285,6 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
foreach (var ds in TwinCATSymbolExpander.ExpandLeaves(
loader.Symbols.Select(s => new AdsSymbolNode(s))))
{
// ThrowIfCancellationRequested — not yield break — so a cancelled browse propagates
// as OperationCanceledException rather than a silent clean completion. DiscoverAsync
// has an explicit catch(OperationCanceledException){ throw; } to surface this
// distinctly from a genuine browse failure; a yield break would let a partial
// symbol set appear as a fully successful discovery (Driver.TwinCAT-010).
cancellationToken.ThrowIfCancellationRequested();
yield return ds;
}
@@ -368,6 +307,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
private readonly IReadOnlyList<ITwinCATSymbolNode> _children;
private readonly bool _readOnly;
/// <summary>
/// Initializes a new instance of the <see cref="AdsSymbolNode"/> class, computing its
/// struct/mapped-type/children/read-only state eagerly from the wrapped <paramref name="symbol"/>.
/// </summary>
/// <param name="symbol">The Beckhoff ADS symbol to adapt.</param>
public AdsSymbolNode(ISymbol symbol)
{
_symbol = symbol;
@@ -379,14 +323,19 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
_readOnly = !IsSymbolWritable(symbol);
}
/// <inheritdoc />
public string InstancePath => _symbol.InstancePath;
/// <inheritdoc />
public bool IsStruct => _isStruct;
/// <inheritdoc />
public (TwinCATDataType? Type, int? ArrayLength) Mapped => _mapped;
/// <inheritdoc />
public IReadOnlyList<ITwinCATSymbolNode> Children => _children;
/// <inheritdoc />
public bool ReadOnly => _readOnly;
}
@@ -577,9 +526,6 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// <summary>Default <see cref="ITwinCATClientFactory"/> — one <see cref="AdsTwinCATClient"/> per call.</summary>
internal sealed class AdsTwinCATClientFactory : ITwinCATClientFactory
{
/// <summary>
/// Creates a new <see cref="AdsTwinCATClient"/> instance.
/// </summary>
/// <returns>A new <see cref="AdsTwinCATClient"/> instance.</returns>
/// <inheritdoc />
public ITwinCATClient Create() => new AdsTwinCATClient();
}
@@ -17,6 +17,7 @@ public interface ITwinCATClient : IDisposable
/// <param name="address">The target AMS address.</param>
/// <param name="timeout">The connection timeout.</param>
/// <param name="cancellationToken">Cancellation token for the connection attempt.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken);
/// <summary>True when the AMS router + target both accept commands.</summary>
@@ -28,7 +29,7 @@ public interface ITwinCATClient : IDisposable
/// 1809 / 0x0711) on any read / write / notification — the signal that a PLC program
/// re-download has invalidated every symbol + notification handle. The driver forwards
/// this to <see cref="Core.Abstractions.IRediscoverable.OnRediscoveryNeeded"/> so Core
/// rebuilds the address space subtree (docs/v2/driver-specs.md §6, Driver.TwinCAT-013).
/// rebuilds the address space subtree (docs/v2/driver-specs.md §6).
/// </summary>
event EventHandler? OnSymbolVersionChanged;
@@ -44,6 +45,7 @@ public interface ITwinCATClient : IDisposable
/// elements; the boxed value is the element-typed CLR array (<c>int[]</c> / <c>float[]</c> /
/// <c>bool[]</c> / <c>string[]</c> / …). When null, read a scalar (Phase 4c).</param>
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
/// <returns>The boxed value read (or <c>null</c>) together with the mapped OPC UA status.</returns>
Task<(object? value, uint status)> ReadValueAsync(
string symbolPath,
TwinCATDataType type,
@@ -60,6 +62,7 @@ public interface ITwinCATClient : IDisposable
/// <param name="bitIndex">Optional bit index for bit manipulation within a word.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">Cancellation token for the write operation.</param>
/// <returns>The mapped OPC UA status for the write (0 = Good).</returns>
Task<uint> WriteValueAsync(
string symbolPath,
TwinCATDataType type,
@@ -72,6 +75,7 @@ public interface ITwinCATClient : IDisposable
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the probe operation.</param>
/// <returns><c>true</c> when the target is reachable; otherwise <c>false</c>.</returns>
Task<bool> ProbeAsync(CancellationToken cancellationToken);
/// <summary>
@@ -86,9 +90,10 @@ public interface ITwinCATClient : IDisposable
/// <param name="cycleTime">Minimum interval between change notifications (native-floor depends on target).</param>
/// <param name="maxDelayMs">Maximum batching delay in milliseconds — TwinCAT may coalesce
/// notifications up to this delay before pushing them. <c>0</c> = no batching, push
/// immediately (Driver.TwinCAT-014).</param>
/// immediately.</param>
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
/// <returns>A handle whose disposal tears the registered notification down.</returns>
Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath,
TwinCATDataType type,
@@ -109,6 +114,7 @@ public interface ITwinCATClient : IDisposable
/// and never see struct-typed entries — expansion is performed here, not in the caller.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the enumeration operation.</param>
/// <returns>An asynchronous stream of the target's atomic-leaf symbols.</returns>
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken);
}
@@ -138,5 +144,6 @@ public sealed record TwinCATDiscoveredSymbol(
public interface ITwinCATClientFactory
{
/// <summary>Creates a new TwinCAT client instance.</summary>
/// <returns>A new, not-yet-connected <see cref="ITwinCATClient"/>.</returns>
ITwinCATClient Create();
}
@@ -13,16 +13,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, IDisposable, IAsyncDisposable
{
// Mutable so ReinitializeAsync can apply a new config generation (Driver.TwinCAT-001).
// The constructor seeds it; InitializeAsync re-parses driverConfigJson over the top of it.
// 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
// (Driver.TwinCAT-009).
// (TryGetValue) don't race — plain Dictionary is not safe for concurrent read+write.
private readonly ConcurrentDictionary<string, DeviceState> _devices =
new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, TwinCATTagDefinition> _tagsByName =
@@ -73,23 +72,20 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
}
/// <summary>Gets the unique driver instance identifier.</summary>
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <summary>Gets the driver type name.</summary>
/// <inheritdoc />
public string DriverType => "TwinCAT";
/// <summary>Initializes the driver with configuration and establishes device connections.</summary>
/// <param name="driverConfigJson">JSON configuration string for the driver.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completion task.</returns>
/// <inheritdoc />
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
// Apply the supplied config generation (Driver.TwinCAT-001). A blank or content-free
// document keeps the constructor-seeded options — that path covers callers that have
// already materialised options up front (the factory passes both, in agreement).
// 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);
@@ -112,7 +108,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
state.ProbeCts = new CancellationTokenSource();
var ct = state.ProbeCts.Token;
// Store the task so ShutdownAsync can await it (Driver.TwinCAT-009).
// Store the task so ShutdownAsync can await it.
state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
@@ -126,19 +122,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
return Task.CompletedTask;
}
/// <summary>Reinitializes the driver by shutting down and reinitializing with new configuration.</summary>
/// <param name="driverConfigJson">JSON configuration string for the driver.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completion task.</returns>
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <summary>Shuts down the driver and releases all device connections and subscriptions.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completion task.</returns>
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
// Native subs first — disposing the handles is cheap + lets the client close its
@@ -150,8 +141,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
await _poll.DisposeAsync().ConfigureAwait(false);
// Cancel every probe loop and await its task before disposing the client + gate so the
// loop can never touch a disposed object. (Driver.TwinCAT-009: ShutdownAsync previously
// cancelled ProbeCts but did not await the task before calling DisposeClient.)
// loop can never touch a disposed object.
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
@@ -176,27 +166,20 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
/// <summary>Gets the current driver health status.</summary>
/// <returns>Driver health information.</returns>
/// <inheritdoc />
public DriverHealth GetHealth() => _health;
/// <summary>
/// Estimated bytes attributable to this driver instance (Driver.TwinCAT-012).
/// This driver holds no flushable symbol cache — <c>BrowseSymbolsAsync</c> 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.
/// </summary>
/// <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() =>
(_tagsByName.Count * 256L) + (_nativeSubs.Count * 512L);
/// <summary>
/// No flushable cache exists in this driver — the symbol table is streamed fresh on
/// every <see cref="DiscoverAsync"/> call. This is a no-op but is deliberately present
/// so Core's cache-budget enforcement sees a compliant Tier-A driver.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completion task.</returns>
/// <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>
@@ -209,10 +192,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// ---- IReadable ----
/// <summary>Reads values for the specified tag references from ADS devices.</summary>
/// <param name="fullReferences">The full tag references to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Data value snapshots for each reference.</returns>
/// <inheritdoc />
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
@@ -272,10 +252,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// ---- IWritable ----
/// <summary>Writes values to the specified tags on ADS devices.</summary>
/// <param name="writes">The write requests to execute.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Write results for each request.</returns>
/// <inheritdoc />
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
@@ -377,18 +354,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// ---- ITagDiscovery ----
/// <summary>
/// Run-once: <see cref="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.
/// </summary>
/// <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>Discovers devices and tags from ADS configuration and optionally controller symbols.</summary>
/// <param name="builder">Address space builder for adding discovered nodes.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completion task.</returns>
/// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -468,17 +441,11 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
private readonly ConcurrentDictionary<long, NativeSubscription> _nativeSubs = new();
private long _nextNativeSubId;
/// <summary>
/// Subscribe via native ADS notifications when <see cref="TwinCATDriverOptions.UseNativeNotifications"/>
/// is <c>true</c>, otherwise fall through to the shared <see cref="PollGroupEngine"/>.
/// Native path registers one <see cref="ITwinCATNotificationHandle"/> per tag against the
/// target's PLC runtime — the PLC pushes changes on its own cycle so we skip the poll
/// loop entirely. Unsub path disposes the handles.
/// </summary>
/// <param name="fullReferences">The full tag references to subscribe to.</param>
/// <param name="publishingInterval">The publishing interval for updates.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Subscription handle for managing the subscription.</returns>
/// <inheritdoc />
// Subscribe via native ADS notifications when TwinCATDriverOptions.UseNativeNotifications is
// true, otherwise fall through to the shared PollGroupEngine. Native path registers one
// ITwinCATNotificationHandle per tag against the target's PLC runtime — the PLC pushes
// changes on its own cycle so we skip the poll loop entirely. Unsub path disposes the handles.
public async Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
@@ -529,10 +496,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
return handle;
}
/// <summary>Unsubscribes from a native or poll-based subscription.</summary>
/// <param name="handle">The subscription handle to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completion task.</returns>
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is NativeSubscriptionHandle native && _nativeSubs.TryRemove(native.Id, out var sub))
@@ -546,7 +510,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
private sealed record NativeSubscriptionHandle(long Id) : ISubscriptionHandle
{
/// <summary>Gets the diagnostic identifier for the subscription.</summary>
/// <inheritdoc />
public string DiagnosticId => $"twincat-native-sub-{Id}";
}
@@ -556,8 +520,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// ---- IHostConnectivityProbe ----
/// <summary>Gets the connectivity status for all configured devices.</summary>
/// <returns>List of host connectivity statuses.</returns>
/// <inheritdoc />
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
@@ -569,7 +532,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
try
{
// Probe-initiated connects honor TwinCATProbeOptions.Timeout — distinct from
// the driver-wide _options.Timeout used by reads/writes (Driver.TwinCAT-014).
// the driver-wide _options.Timeout used by reads/writes.
var client = await EnsureConnectedAsync(state, ct, _options.Probe.Timeout)
.ConfigureAwait(false);
success = await client.ProbeAsync(ct).ConfigureAwait(false);
@@ -614,26 +577,23 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// 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 (Driver.TwinCAT-006).
/// connectivity-status table to share keys.
/// </summary>
public const string UnresolvedHostSentinel = "";
/// <summary>Resolves the device host address for the specified tag reference.</summary>
/// <param name="fullReference">The full tag reference.</param>
/// <returns>The host address or <see cref="UnresolvedHostSentinel"/> if not found.</returns>
/// <inheritdoc />
public string ResolveHost(string fullReference)
{
if (_tagsByName.TryGetValue(fullReference, out var def))
return def.DeviceHostAddress;
// First device's HostAddress when one exists; otherwise the unresolved sentinel —
// intentionally NOT DriverInstanceId, which is a config-DB key, not a host address
// (Driver.TwinCAT-006).
// 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"/> (Driver.TwinCAT-007). Without the gate, a
/// <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
@@ -661,8 +621,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var client = _clientFactory.Create();
client.OnSymbolVersionChanged += HandleSymbolVersionChanged;
// timeoutOverride lets the probe loop use TwinCATProbeOptions.Timeout for probe-
// initiated connects rather than the driver-level _options.Timeout
// (Driver.TwinCAT-014). Reads / writes pass null and get the driver default.
// initiated connects rather than the driver-level _options.Timeout. Reads / writes
// pass null and get the driver default.
var effectiveTimeout = timeoutOverride ?? _options.Timeout;
try
{
@@ -692,7 +652,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <summary>
/// Routes a wire-detected ADS symbol-version-changed (DeviceSymbolVersionInvalid 1809 /
/// 0x0711) to Core as an <see cref="IRediscoverable"/> invocation (Driver.TwinCAT-013).
/// 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.
@@ -705,8 +665,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <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 (Driver.TwinCAT-015,
/// docs/v2/driver-stability.md). The operations here are all genuinely synchronous —
/// 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>
@@ -759,7 +719,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public ITwinCATClient? Client { get; set; }
/// <summary>Serializes connect / reconnect so concurrent callers never race a client
/// create-or-dispose for this device (Driver.TwinCAT-007).</summary>
/// create-or-dispose for this device.</summary>
public SemaphoreSlim ConnectGate { get; } = new(1, 1);
/// <summary>Gets the lock object for synchronizing host state transitions.</summary>
@@ -771,7 +731,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <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 (Driver.TwinCAT-009).</summary>
/// so the loop cannot touch a disposed client.</summary>
public Task? ProbeTask { get; set; }
/// <summary>Disposes the active ADS client if any.</summary>
@@ -24,6 +24,7 @@ public static class TwinCATDriverFactoryExtensions
/// <summary>Creates a TwinCAT driver instance from the provided configuration.</summary>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
/// <returns>The constructed <see cref="TwinCATDriver"/> instance.</returns>
internal static TwinCATDriver CreateInstance(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
@@ -35,10 +36,11 @@ public static class TwinCATDriverFactoryExtensions
/// Parse a TwinCAT driver-config JSON document into a <see cref="TwinCATDriverOptions"/>.
/// Shared by <see cref="CreateInstance"/> (constructor-time) and
/// <see cref="TwinCATDriver.InitializeAsync"/> / <see cref="TwinCATDriver.ReinitializeAsync"/>
/// so a config generation pushed via Reinitialize is actually applied (Driver.TwinCAT-001).
/// so a config generation pushed via Reinitialize is actually applied.
/// </summary>
/// <param name="driverConfigJson">The JSON configuration string.</param>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <returns>The parsed <see cref="TwinCATDriverOptions"/>.</returns>
internal static TwinCATDriverOptions ParseOptions(string driverConfigJson, string driverInstanceId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -79,6 +81,7 @@ public static class TwinCATDriverFactoryExtensions
/// </summary>
/// <param name="driverConfigJson">The JSON configuration string.</param>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <returns>The parsed <see cref="TwinCATDriverOptions"/>.</returns>
public static TwinCATDriverOptions ParseOptionsForTests(string driverConfigJson, string driverInstanceId)
=> ParseOptions(driverConfigJson, driverInstanceId);
@@ -86,13 +89,6 @@ public static class TwinCATDriverFactoryExtensions
{
var dataType = ParseEnum<TwinCATDataType>(t.DataType, t.Name, driverInstanceId, "DataType");
// Driver.TwinCAT-003: Structure-typed pre-declared tags are not supported. The driver's
// atomic surface cannot read/write UDT blobs — MapToClrType falls through to typeof(int)
// and ConvertForWrite throws NotSupportedException, producing garbage reads or late
// runtime failures. BrowseSymbolsAsync expands a Structure/UDT/FB symbol into its atomic
// member leaves (the container itself is never surfaced as a writable Structure node), so the
// supported way to address members is discovery — not a pre-declared Structure tag. Reject here
// with a clear error so operators get a configuration-time failure, not a silent wrong value.
if (dataType == TwinCATDataType.Structure)
throw new InvalidOperationException(
$"TwinCAT tag '{t.Name ?? "<unnamed>"}' in '{driverInstanceId}' specifies " +
@@ -110,9 +106,6 @@ public static class TwinCATDriverFactoryExtensions
DataType: dataType,
Writable: t.Writable ?? true,
WriteIdempotent: t.WriteIdempotent ?? false,
// Driver.TwinCAT-017: thread arrayLength through so JSON-authored pre-declared tags can
// declare 1-D arrays (Phase 4c). Only positive values are honoured; null / non-positive
// keeps the scalar default (ArrayLength: null on TwinCATTagDefinition).
ArrayLength: t.ArrayLength is > 0 ? t.ArrayLength : null);
}
@@ -192,7 +185,7 @@ public static class TwinCATDriverFactoryExtensions
/// <summary>
/// Optional 1-D array element count. When positive, the tag is a 1-D array of this
/// many <see cref="DataType"/> elements — drives <c>IsArray</c>/<c>ArrayDim</c> at
/// discovery and a native ADS array read at runtime (Phase 4c, Driver.TwinCAT-017).
/// discovery and a native ADS array read at runtime (Phase 4c).
/// <c>null</c> or non-positive = scalar (the default).
/// </summary>
public int? ArrayLength { get; init; }
@@ -30,11 +30,11 @@ public static class TwinCATStatusMapper
/// Raised by the runtime after a PLC program re-download: every symbol handle and
/// notification handle the driver holds is now stale. The driver treats this as an
/// <see cref="Core.Abstractions.IRediscoverable"/> trigger, not a connection error
/// (docs/v2/driver-specs.md §6, Driver.TwinCAT-013).
/// (docs/v2/driver-specs.md §6).
/// <para>
/// Note: legacy Beckhoff InfoSys documentation sometimes cites this as "0x0702"; that
/// is a transcription error — 0x0702 is <c>DeviceInvalidGroup</c> (1794). The SDK enum
/// value 1809 (0x0711) is authoritative (Driver.TwinCAT-011).
/// value 1809 (0x0711) is authoritative.
/// </para>
/// </summary>
public const uint AdsSymbolVersionChanged = 1809u; // DeviceSymbolVersionInvalid = 0x0711
@@ -10,6 +10,7 @@ public static class TwinCATSystemSymbolFilter
{
/// <summary>Gets a value indicating whether a symbol is a system or infrastructure symbol.</summary>
/// <param name="instancePath">The symbol instance path to check.</param>
/// <returns><see langword="true"/> if the symbol is a system/infrastructure symbol that should be filtered out; otherwise <see langword="false"/>.</returns>
public static bool IsSystemSymbol(string instancePath)
{
if (string.IsNullOrWhiteSpace(instancePath)) return true;