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
@@ -32,6 +32,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
private readonly ILogger<OpcUaClientDriver> _logger;
/// <summary>Initializes a new instance of the <see cref="OpcUaClientDriver"/> class.</summary>
/// <param name="options">Driver configuration.</param>
/// <param name="driverInstanceId">Stable logical ID from the config DB.</param>
/// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param>
@@ -89,6 +90,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// can be exercised deterministically without standing up a live reconnect handler.
/// Production code never calls this — the SDK reconnect handler owns the real swap.
/// </summary>
/// <param name="session">The session instance to install, or <c>null</c> to clear it.</param>
internal void SetSessionForTest(ISession? session) => Session = session;
private DriverHealth _health = new(DriverState.Unknown, null, null);
@@ -99,7 +101,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// Cert-validation delegate wired when <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/>
/// is <c>true</c>. Stored so <see cref="Dispose"/> / <see cref="DisposeAsync"/> can
/// detach it from the (potentially process-shared) <see cref="CertificateValidator"/>
/// and avoid leaking the closure (Driver.OpcUaClient-012).
/// and avoid leaking the closure.
/// </summary>
private CertificateValidationEventHandler? _certValidationHandler;
/// <summary>The <see cref="CertificateValidator"/> that owns <see cref="_certValidationHandler"/>.</summary>
@@ -107,8 +109,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <summary>
/// Approximate count of discovered nodes (folders + variables). Updated by
/// <see cref="DiscoverAsync"/> and used to report a non-zero
/// <see cref="GetMemoryFootprint"/> to the Core allocation-slope detector
/// (Driver.OpcUaClient-013).
/// <see cref="GetMemoryFootprint"/> to the Core allocation-slope detector.
/// </summary>
private volatile int _discoveredNodeCount;
/// <summary>
@@ -116,7 +117,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// when the session's keep-alive channel reports a bad status. Null outside the
/// reconnecting window; constructed lazily inside the keep-alive handler. Guarded by
/// <see cref="_probeLock"/> — keep-alive callbacks fire from the SDK timer thread and
/// can race a check-then-set if left unsynchronized (Driver.OpcUaClient-005).
/// can race a check-then-set if left unsynchronized.
/// </summary>
private SessionReconnectHandler? _reconnectHandler;
@@ -125,19 +126,17 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// Stored NodeIds embed the server-stable namespace <b>URI</b> rather than the
/// session-relative <c>ns=N</c> index, so a remote-server namespace-table reorder
/// across a restart does not silently re-point stored references at the wrong
/// namespace (driver-specs.md §8 "Namespace Remapping", finding Driver.OpcUaClient-004).
/// namespace (driver-specs.md §8 "Namespace Remapping").
/// Null until <see cref="InitializeAsync"/> returns cleanly.
/// </summary>
private NamespaceMap? _namespaceMap;
/// <summary>Gets the stable logical identifier for this driver instance from the config database.</summary>
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <summary>Gets the driver type identifier.</summary>
/// <inheritdoc />
public string DriverType => "OpcUaClient";
/// <summary>Initializes the OPC UA client driver with the given configuration.</summary>
/// <param name="driverConfigJson">JSON-serialized driver configuration.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
@@ -276,7 +275,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
// AutoAccept=true is a dev-only escape hatch. Emit a prominent warning so a
// production misconfiguration is immediately visible in logs (Driver.OpcUaClient-012).
// production misconfiguration is immediately visible in logs.
if (_options.AutoAcceptCertificates)
{
_logger.LogWarning(
@@ -289,7 +288,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Accept the full set of certificate-validation error codes: a real dev cert can
// fail with BadCertificateChainIncomplete, BadCertificateTimeInvalid, or
// BadCertificateHostNameInvalid, not only BadCertificateUntrusted. Only accepting
// the latter would silently fail for those certs (Driver.OpcUaClient-012).
// the latter would silently fail for those certs.
CertificateValidationEventHandler handler = (_, e) => e.Accept = true;
config.CertificateValidator.CertificateValidation += handler;
// Store refs so ShutdownAsync + Dispose can detach the delegate and avoid
@@ -312,6 +311,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// existing single-endpoint configs keep working without migration.
/// </summary>
/// <param name="opts">Driver options containing endpoint configuration.</param>
/// <returns>The ordered list of endpoint URLs to try.</returns>
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts)
{
if (opts.EndpointUrls is { Count: > 0 }) return opts.EndpointUrls;
@@ -361,6 +361,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// private key N times, wasteful + keeps the password in memory longer.
/// </summary>
/// <param name="options">Driver options containing authentication configuration.</param>
/// <returns>The constructed <see cref="UserIdentity"/> for session activation.</returns>
internal static UserIdentity BuildUserIdentity(OpcUaClientDriverOptions options) =>
options.AuthType switch
{
@@ -464,6 +465,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// challenge during session activation).
/// </summary>
/// <param name="options">Driver options containing certificate configuration.</param>
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
internal static UserIdentity BuildCertificateIdentity(OpcUaClientDriverOptions options)
{
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
@@ -491,6 +493,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
/// <param name="policy">The driver security policy to map.</param>
/// <returns>The corresponding OPC UA security policy URI.</returns>
internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch
{
OpcUaSecurityPolicy.None => SecurityPolicies.None,
@@ -505,17 +508,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
private static string ShortPolicyName(string policyUri) =>
policyUri?.Substring(policyUri.LastIndexOf('#') + 1) ?? "(null)";
/// <summary>Reinitializes the driver with new configuration, shutting down and restarting the session.</summary>
/// <param name="driverConfigJson">JSON-serialized driver configuration.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <summary>Gracefully shuts down the OPC UA session, unsubscribing all active monitoring items and closing the connection.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
// Tear down remote subscriptions first — otherwise Session.Close will try and may fail
@@ -523,7 +523,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// whether or not the wire-side delete succeeds since the local handles are useless
// after close anyway. Before deleting each subscription we detach the Notification
// handlers we attached at subscribe time so the SDK's invocation list no longer
// holds the driver instance through the closure (Driver.OpcUaClient-014).
// holds the driver instance through the closure.
foreach (var rs in _subscriptions.Values)
{
DetachNotificationHandlers(rs.ItemHandlers);
@@ -544,8 +544,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Abort any in-flight reconnect attempts before touching the session — BeginReconnect's
// retry loop holds a reference to the current session and would fight Session.CloseAsync
// if left spinning. Take the handler under _probeLock so a keep-alive callback racing
// through OnKeepAlive can't arm a fresh handler after we've torn this one down
// (Driver.OpcUaClient-005).
// through OnKeepAlive can't arm a fresh handler after we've torn this one down.
SessionReconnectHandler? handlerToCancel;
lock (_probeLock)
{
@@ -556,9 +555,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
handlerToCancel?.Dispose();
// Take the session reference under _probeLock before touching it, so we can't race
// an OnReconnectComplete that is simultaneously swapping to a new session
// (Driver.OpcUaClient-006). We clear Session to null here so any concurrent caller
// that checks inside _gate sees null immediately after shutdown begins.
// an OnReconnectComplete that is simultaneously swapping to a new session. We clear
// Session to null here so any concurrent caller that checks inside _gate sees null
// immediately after shutdown begins.
ISession? sessionToClose;
lock (_probeLock)
{
@@ -578,8 +577,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
_connectedEndpointUrl = null;
// Detach the cert-validation handler so the (potentially process-shared)
// CertificateValidator doesn't hold a delegate to a shutting-down driver
// (Driver.OpcUaClient-012).
// CertificateValidator doesn't hold a delegate to a shutting-down driver.
if (_certValidationHandler is not null && _certValidatorRef is not null)
{
try { _certValidatorRef.CertificateValidation -= _certValidationHandler; } catch { }
@@ -591,28 +589,21 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
/// <summary>Gets the current health status of the OPC UA client driver.</summary>
/// <inheritdoc />
public DriverHealth GetHealth() => _health;
/// <summary>
/// Returns an approximate in-driver memory footprint for the Core allocation-slope
/// detector. Each discovered node (folder or variable) contributes ~512 bytes to cover
/// the <see cref="DriverAttributeInfo"/> record, the browse-name string, and the stable
/// <c>nsu=</c> reference string stored in the address-space builder. The real number
/// depends on string length + box sizes; the constant is conservative enough that a
/// 10k-node remote server reports ~5 MB — well within the budget and detectable by the
/// Core slope alarm (Driver.OpcUaClient-013).
/// </summary>
/// <inheritdoc />
// Each discovered node (folder or variable) contributes ~512 bytes to cover the
// DriverAttributeInfo record, the browse-name string, and the stable nsu= reference string
// stored in the address-space builder. The real number depends on string length + box sizes;
// the constant is conservative enough that a 10k-node remote server reports ~5 MB — well
// within the budget and detectable by the Core slope alarm.
public long GetMemoryFootprint() => _discoveredNodeCount * 512L;
/// <summary>
/// Drops the discovered-node count so the Core's cache-budget enforcement can request
/// a flush when footprint budget is breached. The OPC UA Client driver holds no
/// independently-flushable cache beyond what the address-space builder retains — a
/// flush here resets the footprint counter and signals the Core that re-discovery
/// will rebuild it cleanly from the remote server.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
// The OPC UA Client driver holds no independently-flushable cache beyond what the
// address-space builder retains — a flush here resets the discovered-node footprint
// counter and signals Core that re-discovery will rebuild it cleanly from the remote server.
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
{
_discoveredNodeCount = 0;
@@ -621,15 +612,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// ---- IReadable ----
/// <summary>Reads the current values of the specified nodes from the remote OPC UA server.</summary>
/// <param name="fullReferences">Fully-qualified node identifiers to read.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
// Make sure a session exists before queuing on the gate, but do NOT bind the wire
// call to this reference — a reconnect can swap Session while we wait on _gate. The
// session actually used is re-read inside the gate (Driver.OpcUaClient-001/-006).
// session actually used is re-read inside the gate.
_ = RequireSession();
var results = new DataValueSnapshot[fullReferences.Count];
var now = DateTime.UtcNow;
@@ -712,14 +701,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// ---- IWritable ----
/// <summary>Writes values to the specified nodes on the remote OPC UA server.</summary>
/// <param name="writes">Write requests specifying nodes and values to write.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<Core.Abstractions.WriteRequest> writes, CancellationToken cancellationToken)
{
// See ReadAsync — the wire call must use the session current inside the gate, not a
// reference captured before WaitAsync (Driver.OpcUaClient-001/-006).
// reference captured before WaitAsync.
_ = RequireSession();
var results = new WriteResult[writes.Count];
@@ -729,9 +716,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var session = Session;
if (session is null)
{
// Writes are non-idempotent (decision #44/#45) — but here the request never
// reached the wire, so BadCommunicationError ("definitely did not happen") is
// the honest code.
// Writes are non-idempotent — but here the request never reached the wire, so
// BadCommunicationError ("definitely did not happen") is the honest code.
for (var i = 0; i < writes.Count; i++)
results[i] = new WriteResult(StatusBadCommunicationError);
return results;
@@ -777,10 +763,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
catch (OperationCanceledException)
{
// Timeout / cancellation after the wire request may have been dispatched.
// Writes are non-idempotent (decision #44/#45) — BadTimeout ("outcome unknown,
// do not blindly retry") is more honest than BadCommunicationError ("definitely
// did not happen"). Downstream callers that need retry semantics check for
// BadTimeout and can decide whether to re-issue (Driver.OpcUaClient-009).
// Writes are non-idempotent — BadTimeout ("outcome unknown, do not blindly
// retry") is more honest than BadCommunicationError ("definitely did not
// happen"). Downstream callers that need retry semantics check for BadTimeout
// and can decide whether to re-issue.
const uint StatusBadTimeout = 0x800A0000u;
for (var w = 0; w < indexMap.Count; w++)
results[indexMap[w]] = new WriteResult(StatusBadTimeout);
@@ -810,6 +796,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <param name="session">The OPC UA session to resolve the node ID against.</param>
/// <param name="fullReference">The full reference string to parse.</param>
/// <param name="nodeId">The parsed node ID when successful.</param>
/// <returns><c>true</c> if the reference was parsed successfully; otherwise <c>false</c>.</returns>
internal static bool TryParseNodeId(ISession session, string fullReference, out NodeId nodeId) =>
NamespaceMap.TryResolve(session, fullReference, out nodeId);
@@ -826,23 +813,19 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// ---- ITagDiscovery ----
/// <summary>
/// Run-once: <see cref="DiscoverAsync"/> recursively browses the remote server's address
/// space and registers every variable within the single call (browse + enrich passes are
/// fully awaited) — nothing fills in asynchronously after connect, so a single discovery
/// pass is sufficient.
/// </summary>
/// <inheritdoc />
// Run-once: DiscoverAsync recursively browses the remote server's address space and
// registers every variable within the single call (browse + enrich passes are fully
// awaited) — nothing fills in asynchronously after connect, so a single discovery pass
// is sufficient.
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <summary>Discovers the remote OPC UA server's address space and materializes it through the supplied builder.</summary>
/// <param name="builder">Address space builder for materializing discovered nodes.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
// Confirm a session exists before queuing; the session actually browsed is re-read
// inside the gate so a reconnect mid-wait can't leave us browsing a closed session
// (Driver.OpcUaClient-001/-006).
// inside the gate so a reconnect mid-wait can't leave us browsing a closed session.
_ = RequireSession();
var rootFolder = builder.Folder("Remote", "Remote");
@@ -880,7 +863,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
.ConfigureAwait(false);
// Update the footprint counter so GetMemoryFootprint() returns a real estimate
// after each discovery pass (Driver.OpcUaClient-013).
// after each discovery pass.
_discoveredNodeCount = discovered;
}
finally { _gate.Release(); }
@@ -937,8 +920,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// per node in a single response; when a folder has more children than the cap,
// BrowseResult.ContinuationPoint is non-empty and the remainder must be pulled
// with BrowseNext. Without this loop a large remote folder is silently truncated
// and discovered tags go missing from the local address space
// (Driver.OpcUaClient-003).
// and discovered tags go missing from the local address space.
var continuationPoint = result.ContinuationPoint;
while (continuationPoint is { Length: > 0 })
{
@@ -954,8 +936,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
catch (OperationCanceledException)
{
// Caller cancelled mid-pagination. Release the server-side cursor so it
// doesn't hold resources until session close (Driver.OpcUaClient-016;
// same pattern as Driver.OpcUaClient.Browser-002). Use CancellationToken.None
// doesn't hold resources until session close. Use CancellationToken.None
// for the release — the original token is already cancelled so we must not
// gate the release on it. Fire-and-forget: if the server is also unreachable,
// the release fails silently and the cursor times out server-side anyway.
@@ -1132,13 +1113,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// StatusCode + timestamps.
/// </summary>
/// <param name="dataType">The OPC UA data type NodeId to map.</param>
/// <returns>The mapped <see cref="DriverDataType"/>.</returns>
internal static DriverDataType MapUpstreamDataType(NodeId dataType)
{
if (dataType == DataTypeIds.Boolean) return DriverDataType.Boolean;
// SByte (signed 8-bit) shares Int16 — DriverDataType has no narrower signed type.
// Byte (unsigned 8-bit) belongs in the unsigned family → UInt16, not Int16
// (Driver.OpcUaClient-010: mapping an unsigned 0-255 type onto Int16 misrepresents
// type metadata and confuses range/validation logic keyed off DriverDataType).
// (mapping an unsigned 0-255 type onto Int16 misrepresents type metadata and
// confuses range/validation logic keyed off DriverDataType).
if (dataType == DataTypeIds.SByte || dataType == DataTypeIds.Int16) return DriverDataType.Int16;
if (dataType == DataTypeIds.Byte || dataType == DataTypeIds.UInt16) return DriverDataType.UInt16;
if (dataType == DataTypeIds.Int32) return DriverDataType.Int32;
@@ -1160,6 +1142,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <see cref="SecurityClassification.Operate"/>; read-only as <see cref="SecurityClassification.ViewOnly"/>.
/// </summary>
/// <param name="accessLevel">The OPC UA access level bitmask.</param>
/// <returns>The mapped security classification.</returns>
internal static SecurityClassification MapAccessLevelToSecurityClass(byte accessLevel)
{
const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02
@@ -1170,16 +1153,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// ---- ISubscribable ----
/// <summary>Subscribes to monitored value changes on the specified nodes from the remote OPC UA server.</summary>
/// <param name="fullReferences">Fully-qualified node identifiers to monitor.</param>
/// <param name="publishingInterval">Desired minimum interval between publish cycles.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
// Fast-fail before queuing if the driver isn't connected, but do NOT bind the
// subscription to this reference — a reconnect can swap Session while we wait on _gate.
// The session actually used is re-read inside the gate (Driver.OpcUaClient-001/-006).
// The session actually used is re-read inside the gate.
_ = RequireSession();
var id = Interlocked.Increment(ref _nextSubscriptionId);
var handle = new OpcUaSubscriptionHandle(id);
@@ -1215,9 +1195,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
// Track each (MonitoredItem, handler) pair so UnsubscribeAsync / ShutdownAsync
// can detach the Notification delegate before disposing the session
// (Driver.OpcUaClient-014). The lambda captures `handle`, so we must hold the
// exact delegate instance returned by `+=` to be able to remove it.
// can detach the Notification delegate before disposing the session. The lambda
// captures `handle`, so we must hold the exact delegate instance returned by
// `+=` to be able to remove it.
var itemHandlers = new List<MonitoredItemNotificationHandle>();
foreach (var fullRef in fullReferences)
{
@@ -1252,9 +1232,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
return handle;
}
/// <summary>Unsubscribes from monitored value changes for the specified subscription handle.</summary>
/// <param name="handle">The subscription handle to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not OpcUaSubscriptionHandle h) return;
@@ -1265,9 +1243,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
// Detach Notification handlers BEFORE deleting the subscription so the SDK's
// MonitoredItem.Notification multicast invocation list no longer holds a
// closure that captures the driver instance (Driver.OpcUaClient-014). The
// delegate stored on RemoteSubscription is the exact instance that was added,
// so `-=` removes it cleanly.
// closure that captures the driver instance. The delegate stored on
// RemoteSubscription is the exact instance that was added, so `-=` removes it
// cleanly.
DetachNotificationHandlers(rs.ItemHandlers);
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort — the subscription may already be gone on reconnect */ }
@@ -1305,8 +1283,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// Live data-change subscription bookkeeping. Holds the SDK <see cref="Subscription"/>,
/// the local handle, and the per-MonitoredItem (item, handler) pairs so
/// <see cref="UnsubscribeAsync"/> / <see cref="ShutdownAsync"/> can detach the
/// Notification delegates before the SDK disposes the subscription
/// (Driver.OpcUaClient-014).
/// Notification delegates before the SDK disposes the subscription.
/// </summary>
private sealed record RemoteSubscription(
Subscription Subscription,
@@ -1326,7 +1303,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
private sealed record OpcUaSubscriptionHandle(long Id) : ISubscriptionHandle
{
/// <summary>Gets the diagnostic identifier for this subscription.</summary>
/// <inheritdoc />
public string DiagnosticId => $"opcua-sub-{Id}";
}
@@ -1344,15 +1321,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
private const int AlarmFieldTime = 5;
private const int AlarmFieldConditionId = 6;
/// <summary>Subscribes to alarm and event notifications from the remote OPC UA server.</summary>
/// <param name="sourceNodeIds">Source node identifiers to subscribe alarms from.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
// Fast-fail before queuing if the driver isn't connected, but do NOT bind the alarm
// subscription to this reference — a reconnect can swap Session while we wait on _gate.
// The session actually used is re-read inside the gate (Driver.OpcUaClient-001/-006).
// The session actually used is re-read inside the gate.
_ = RequireSession();
var id = Interlocked.Increment(ref _nextAlarmSubscriptionId);
var handle = new OpcUaAlarmSubscriptionHandle(id);
@@ -1424,9 +1399,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
Handle = handle,
};
// Capture the exact delegate instance so UnsubscribeAlarmsAsync / ShutdownAsync
// can `-=` it later (Driver.OpcUaClient-014). The lambda captures `handle` and
// `sourceFilter`, so without the explicit detach the SDK's invocation list keeps
// the driver instance alive until the session itself is disposed.
// can `-=` it later. The lambda captures `handle` and `sourceFilter`, so without
// the explicit detach the SDK's invocation list keeps the driver instance alive
// until the session itself is disposed.
MonitoredItemNotificationEventHandler notifHandler = (mi, args) =>
OnEventNotification(handle, sourceFilter, mi, args);
eventItem.Notification += notifHandler;
@@ -1440,9 +1415,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
return handle;
}
/// <summary>Unsubscribes from alarm and event notifications for the specified alarm subscription handle.</summary>
/// <param name="handle">The alarm subscription handle to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not OpcUaAlarmSubscriptionHandle h) return;
@@ -1453,7 +1426,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
// Detach the Notification handler before deleting the subscription so the SDK's
// multicast invocation list no longer holds the driver instance through the
// closure (Driver.OpcUaClient-014).
// closure.
try { rs.EventItem.Notification -= rs.Handler; }
catch { /* best-effort */ }
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
@@ -1462,9 +1435,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
finally { _gate.Release(); }
}
/// <summary>Acknowledges multiple alarms by calling the remote OPC UA server's Acknowledge method.</summary>
/// <param name="acknowledgements">List of alarm acknowledgement requests.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
{
@@ -1474,8 +1445,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
if (acknowledgements.Count == 0) return;
// Fast-fail before queuing if the driver isn't connected, but do NOT bind the ack calls
// (or the namespace-relative ConditionId parse) to this reference — a reconnect can swap
// Session while we wait on _gate. The session actually used is re-read inside the gate
// (Driver.OpcUaClient-001/-006).
// Session while we wait on _gate. The session actually used is re-read inside the gate.
_ = RequireSession();
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -1518,7 +1488,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Inspect per-ack results — the upstream server can reject individual acks
// (BadConditionAlreadyAcked, BadNodeIdUnknown, BadUserAccessDenied) even when
// the batch transport succeeds. Operators acking a critical alarm deserve to
// know if the ack didn't take (Driver.OpcUaClient-008).
// know if the ack didn't take.
if (resp?.Results is not null)
{
for (var i = 0; i < resp.Results.Count; i++)
@@ -1590,6 +1560,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// guidance: 1-200 Low, 201-500 Medium, 501-800 High, 801-1000 Critical.
/// </summary>
/// <param name="opcSeverity">The OPC UA severity value (1-1000).</param>
/// <returns>The mapped coarse-grained alarm severity bucket.</returns>
internal static AlarmSeverity MapSeverity(ushort opcSeverity) => opcSeverity switch
{
<= 200 => AlarmSeverity.Low,
@@ -1602,7 +1573,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// Live alarm-event subscription bookkeeping. Holds the SDK <see cref="Subscription"/>,
/// the local handle, the single event-MonitoredItem (`Server/Events`), and the exact
/// handler delegate instance so unsubscribe / shutdown can detach the Notification
/// event before the SDK disposes the subscription (Driver.OpcUaClient-014).
/// event before the SDK disposes the subscription.
/// </summary>
private sealed record RemoteAlarmSubscription(
Subscription Subscription,
@@ -1612,18 +1583,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
private sealed record OpcUaAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
{
/// <summary>Gets the diagnostic identifier for this alarm subscription.</summary>
/// <inheritdoc />
public string DiagnosticId => $"opcua-alarm-sub-{Id}";
}
// ---- IHistoryProvider (passthrough to upstream server) ----
/// <summary>Reads raw historical data from the remote OPC UA server.</summary>
/// <param name="fullReference">Fully-qualified node identifier to read history for.</param>
/// <param name="startUtc">Start time in UTC for the history query.</param>
/// <param name="endUtc">End time in UTC for the history query.</param>
/// <param name="maxValuesPerNode">Maximum number of values to return.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task<Core.Abstractions.HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
@@ -1640,13 +1606,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
.ConfigureAwait(false);
}
/// <summary>Reads processed (aggregated) historical data from the remote OPC UA server.</summary>
/// <param name="fullReference">Fully-qualified node identifier to read history for.</param>
/// <param name="startUtc">Start time in UTC for the history query.</param>
/// <param name="endUtc">End time in UTC for the history query.</param>
/// <param name="interval">Time interval for aggregation.</param>
/// <param name="aggregate">The aggregation function to apply.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task<Core.Abstractions.HistoryReadResult> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
@@ -1663,10 +1623,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
.ConfigureAwait(false);
}
/// <summary>Reads historical data at specific timestamps from the remote OPC UA server.</summary>
/// <param name="fullReference">Fully-qualified node identifier to read history for.</param>
/// <param name="timestampsUtc">List of specific timestamps to read values at.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <inheritdoc />
public async Task<Core.Abstractions.HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
{
@@ -1690,8 +1647,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
// Make sure a session exists before queuing on the gate, but do NOT bind the wire call
// (or the namespace-relative NodeId parse) to this reference — a reconnect can swap
// Session while we wait on _gate. The session actually used is re-read inside the gate
// (Driver.OpcUaClient-001/-006).
// Session while we wait on _gate. The session actually used is re-read inside the gate.
_ = RequireSession();
await _gate.WaitAsync(ct).ConfigureAwait(false);
@@ -1748,6 +1704,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <summary>Map <see cref="HistoryAggregateType"/> to the OPC UA Part 13 standard aggregate NodeId.</summary>
/// <param name="aggregate">The aggregation function type to map.</param>
/// <returns>The standard aggregate function NodeId.</returns>
internal static NodeId MapAggregateToNodeId(HistoryAggregateType aggregate) => aggregate switch
{
HistoryAggregateType.Average => ObjectIds.AggregateFunction_Average,
@@ -1847,30 +1804,22 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return 0; }
}
/// <summary>
/// Forwards OPC UA HistoryReadEvents to the upstream server. Sends the fixed canonical
/// <see cref="BuildBaseEventFilter"/> and maps the result onto <see cref="HistoricalEvent"/>
/// (the OtOpcUa server projects only those six BaseEventType fields, so a richer client
/// filter would be discarded server-side — the driver supplies the canonical set itself).
/// </summary>
/// <param name="sourceName">
/// Upstream event-notifier NodeId to read from (mirrors how <c>fullReference</c> is the
/// upstream NodeId for raw reads). Null/empty → the upstream Server object (<c>i=2253</c>),
/// the standard server-wide event notifier. An unparseable id short-circuits to an empty
/// result, matching the raw path's malformed-NodeId behavior.
/// </param>
/// <param name="startUtc">Inclusive lower bound on event time (UTC).</param>
/// <param name="endUtc">Exclusive upper bound on event time (UTC).</param>
/// <param name="maxEvents">Upper cap; <c>&lt;= 0</c> means "no cap" (NumValuesPerNode = 0).</param>
/// <param name="cancellationToken">Request cancellation.</param>
/// <returns>The historical events plus the upstream continuation point (null when complete).</returns>
/// <inheritdoc />
// Forwards OPC UA HistoryReadEvents to the upstream server. Sends the fixed canonical
// BuildBaseEventFilter and maps the result onto HistoricalEvent (the OtOpcUa server projects
// only those six BaseEventType fields, so a richer client filter would be discarded
// server-side — the driver supplies the canonical set itself). sourceName is the upstream
// event-notifier NodeId to read from (mirrors how fullReference is the upstream NodeId for
// raw reads); null/empty resolves to the upstream Server object (i=2253), the standard
// server-wide event notifier, and an unparseable id short-circuits to an empty result,
// matching the raw path's malformed-NodeId behavior.
public async Task<Core.Abstractions.HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken)
{
// Confirm a session exists before queuing; the session actually used — and the
// namespace-relative source-NodeId parse — are re-read inside the gate so a reconnect
// mid-wait can't leave us reading from a closed session (Driver.OpcUaClient-001/-006).
// mid-wait can't leave us reading from a closed session.
_ = RequireSession();
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -1943,7 +1892,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
?? ResolveEndpointCandidates(_options).FirstOrDefault()
?? _options.EndpointUrl;
/// <summary>Gets the current connectivity status of the remote OPC UA server host.</summary>
/// <inheritdoc />
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
@@ -1971,7 +1920,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// while the channel stays down — the check-then-set must be atomic, otherwise two
// callbacks both observe null, both construct a SessionReconnectHandler, and the
// second assignment leaks the first (its retry loop keeps running, unreferenced and
// never disposed). Guard with _probeLock (Driver.OpcUaClient-005).
// never disposed). Guard with _probeLock.
SessionReconnectHandler handler;
lock (_probeLock)
{
@@ -2008,7 +1957,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// OnReconnectComplete, OnKeepAlive, and ShutdownAsync cannot race each other:
// a session swap visible to concurrent ReadAsync/WriteAsync/DiscoverAsync callers
// (which re-read Session inside _gate) must be atomic w.r.t. disposal and
// re-arming (Driver.OpcUaClient-006).
// re-arming.
ISession? oldSession;
lock (_probeLock)
{
@@ -2051,10 +2000,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// maxReconnectPeriod and invoked the callback with a null Session. Without an
// explicit Faulted signal the driver is permanently wedged: no session, no live
// keep-alive to re-trigger OnKeepAlive, and the Core never learns it must offer an
// operator reinitialize (Driver.OpcUaClient-002). Surface Faulted so the Core fans
// out Bad quality and ReinitializeAsync becomes available, and arm a fresh reconnect
// attempt against the last-known session for an always-on gateway rather than
// abandoning recovery entirely.
// operator reinitialize. Surface Faulted so the Core fans out Bad quality and
// ReinitializeAsync becomes available, and arm a fresh reconnect attempt against
// the last-known session for an always-on gateway rather than abandoning recovery
// entirely.
TransitionTo(HostState.Faulted);
_health = new DriverHealth(
DriverState.Faulted, _health.LastSuccessfulRead,
@@ -2127,7 +2076,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// handler detach. The async session-close is intentionally skipped — it requires a
/// live session + network round-trip and is unsafe to block-on from a potentially
/// single-threaded context (OPC UA stack thread). The session will be cleaned up by
/// the SDK's own finalizer on GC (Driver.OpcUaClient-007: no sync-over-async).
/// the SDK's own finalizer on GC (no sync-over-async).
/// </summary>
public void Dispose()
{
@@ -2154,7 +2103,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Detach the cert-validation handler registered during InitializeAsync so the
// CertificateValidator (which may be process-shared) doesn't hold a reference to
// a disposed driver (Driver.OpcUaClient-012).
// a disposed driver.
if (_certValidationHandler is not null && _certValidatorRef is not null)
{
try { _certValidatorRef.CertificateValidation -= _certValidationHandler; } catch { }
@@ -2165,7 +2114,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Acquire the gate once so any in-flight gated operation (ReadAsync / WriteAsync /
// DiscoverAsync) has definitely released before we dispose the gate. Without this
// drain, a background read that calls _gate.Release() after Dispose throws
// ObjectDisposedException (Driver.OpcUaClient-007).
// ObjectDisposedException.
try
{
if (_gate.Wait(TimeSpan.FromSeconds(2)))
@@ -2176,6 +2125,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
}
/// <summary>Asynchronously disposes the driver and releases all associated resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
@@ -2183,7 +2133,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
catch { /* disposal is best-effort */ }
// Detach the cert-validation handler (Driver.OpcUaClient-012).
// Detach the cert-validation handler.
if (_certValidationHandler is not null && _certValidatorRef is not null)
{
try { _certValidatorRef.CertificateValidation -= _certValidationHandler; } catch { }
@@ -2192,7 +2142,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
}
// Drain the gate before disposal so no in-flight _gate.Release() fires after
// Dispose (Driver.OpcUaClient-007).
// Dispose.
try
{
await _gate.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);