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
@@ -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>