Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
T

1894 lines
92 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <summary>
/// Real OPC UA client implementation using the OPC Foundation .NET Standard Library.
/// Wraps Session, Subscription, and MonitoredItem for tag subscriptions.
/// </summary>
public class RealOpcUaClient : IOpcUaClient
{
private ISession? _session;
/// <summary>
/// One data shard = one OPC UA Subscription holding at most
/// <see cref="OpcUaConnectionOptions.MaxMonitoredItemsPerSubscription"/> monitored
/// items. The count is tracked here rather than read back off the SDK object so
/// placement decisions are made from state this class owns (and mutates only under
/// <see cref="_applyGate"/>).
/// </summary>
private sealed class DataShard(Subscription subscription)
{
/// <summary>The OPC UA subscription backing this shard.</summary>
public Subscription Subscription { get; } = subscription;
/// <summary>The number of monitored items currently placed on this shard.</summary>
public int ItemCount { get; set; }
}
// Data-item shards, in creation order. A monitored item is placed on the first shard
// with free capacity; when all are full a new shard is created, and a shard is
// deleted once it empties. Mutated ONLY under _applyGate.
private readonly List<DataShard> _dataShards = new();
// Alarms & Conditions event items live on their own shard: TriggerConditionRefresh
// needs a single subscription id to target, and QueueSize:1000 event items would
// otherwise consume data-shard capacity. Created lazily, torn down on disconnect.
private Subscription? _eventShard;
// subscription handle → owning shard's Subscription, so a removal can group ONE
// ApplyChanges per touched shard.
private readonly ConcurrentDictionary<string, Subscription> _itemShard = new();
// The OPC Foundation Subscription object is not safe under concurrent structural
// mutation, and nothing else orders HandleSubscribe's background task, the
// tag-resolution probe and an alarm subscribe against each other. Every
// AddItem/RemoveItem/ApplyChanges/Create/Delete — and every shard-list mutation —
// serializes behind this ONE per-client gate. Batching makes contention rare; a
// single gate (rather than per-shard) also serializes shard creation and is the
// simplest thing that is correct.
private readonly AsyncSerialGate _applyGate = new();
// These maps are read from the OPC Foundation SDK's
// internal publish threads (the MonitoredItem.Notification handler reads
// _callbacks) concurrently with subscribe/disconnect mutations that run on
// thread-pool threads. Plain Dictionary access during a concurrent resize or
// Clear() is undefined behaviour, so they must be ConcurrentDictionary.
private readonly ConcurrentDictionary<string, MonitoredItem> _monitoredItems = new();
private readonly ConcurrentDictionary<string, Action<string, object?, DateTime, uint>> _callbacks = new();
// Native alarm (A&C) event subscriptions, keyed by handle.
private readonly ConcurrentDictionary<string, MonitoredItem> _alarmItems = new();
// Per-handle "currently inside a ConditionRefresh replay" flag → Snapshot kind.
private readonly ConcurrentDictionary<string, bool> _alarmInRefresh = new();
private readonly ConcurrentDictionary<string, Guid> _alarmSnapshotIds = new();
// Per-handle last (active, acked) by source reference, to derive transition kind.
private readonly ConcurrentDictionary<string, Dictionary<string, (bool Active, bool Acked)>> _alarmLastState = new();
// Int flag toggled with Interlocked.Exchange so the
// once-only ConnectionLost guard in OnSessionKeepAlive is atomic, not just visible.
// 0 = not fired, 1 = fired.
private int _connectionLostFired;
private OpcUaConnectionOptions _options = new();
private readonly OpcUaGlobalOptions _globalOptions;
private readonly ILogger<RealOpcUaClient> _logger;
/// <summary>
/// Initializes a new instance of the RealOpcUaClient class.
/// </summary>
/// <param name="globalOptions">Global OPC UA options, or null to use defaults.</param>
/// <param name="logger">Logger instance, or null to use a null logger.</param>
public RealOpcUaClient(OpcUaGlobalOptions? globalOptions = null, ILogger<RealOpcUaClient>? logger = null)
{
_globalOptions = globalOptions ?? new OpcUaGlobalOptions();
_logger = logger ?? NullLogger<RealOpcUaClient>.Instance;
}
/// <inheritdoc />
public bool IsConnected => _session?.Connected ?? false;
/// <summary>
/// Asynchronous endpoint discovery bounded by <paramref name="appConfig"/>'s
/// <c>TransportQuotas.OperationTimeout</c> (the connection's <c>OperationTimeoutMs</c>) and by
/// <paramref name="ct"/> (review 2026-10-21 02/P2). It replaces the synchronous
/// <c>DiscoveryClient.Create(uri).GetEndpoints(null)</c>, which ran on whatever thread called
/// the connect — the actor thread, when <c>ValidateAsync</c> completed synchronously — under
/// the SDK's default 120 s operation timeout, and could not be cancelled by a verify probe's
/// budget.
/// </summary>
private static async Task<EndpointDescriptionCollection> DiscoverEndpointsAsync(
ApplicationConfiguration appConfig, string endpointUrl, CancellationToken ct)
{
var discoveryConfig = EndpointConfiguration.Create(appConfig);
using var discoveryClient = await DiscoveryClient.CreateAsync(
appConfig, new Uri(endpointUrl), discoveryConfig, DiagnosticsMasks.None, ct).ConfigureAwait(false);
return await discoveryClient.GetEndpointsAsync(null, ct).ConfigureAwait(false);
}
/// <summary>Raised when the OPC UA connection is lost.</summary>
public event Action? ConnectionLost;
/// <summary>Selects exactly the requested protection mode; protected discovery failures fail closed.</summary>
/// <param name="url">The discovery URL to query.</param>
/// <param name="requested">The security mode that must be matched exactly.</param>
/// <param name="discover">Delegate that returns the server's advertised endpoints, honouring the token.</param>
/// <param name="ct">
/// Bounds the discovery. A cancellation is rethrown — never mistaken for "discovery
/// unavailable", which would otherwise fall back to an unsecured endpoint and carry on.
/// </param>
/// <returns>The endpoint matching the requested security mode.</returns>
internal static async Task<EndpointDescription> DiscoverEndpointAsync(string url, MessageSecurityMode requested,
Func<CancellationToken, Task<EndpointDescriptionCollection>> discover, CancellationToken ct = default)
{
EndpointDescriptionCollection endpoints;
try { endpoints = await discover(ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
if (requested != MessageSecurityMode.None)
throw new InvalidOperationException($"OPC UA endpoint discovery failed; requested security mode {requested} cannot be verified.", ex);
return new EndpointDescription(url);
}
return endpoints.FirstOrDefault(e => e.SecurityMode == requested)
?? throw new InvalidOperationException($"OPC UA requested security mode {requested} is unavailable. Available modes: {string.Join(", ", endpoints.Select(e => e.SecurityMode).Distinct())}.");
}
/// <inheritdoc />
public async Task ConnectAsync(string endpointUrl, OpcUaConnectionOptions? options = null, CancellationToken cancellationToken = default)
{
var opts = options ?? new OpcUaConnectionOptions();
var preferredSecurityMode = opts.SecurityMode?.ToUpperInvariant() switch
{
"SIGN" => MessageSecurityMode.Sign,
"SIGNANDENCRYPT" => MessageSecurityMode.SignAndEncrypt,
_ => MessageSecurityMode.None
};
var appConfig = new ApplicationConfiguration
{
ApplicationName = string.IsNullOrWhiteSpace(_globalOptions.ApplicationName)
? "ScadaBridge-DCL"
: _globalOptions.ApplicationName,
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
AutoAcceptUntrustedCertificates = opts.AutoAcceptUntrustedCerts,
ApplicationCertificate = new CertificateIdentifier(),
TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedIssuerStorePath, "issuers") },
TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.TrustedPeerStorePath, "trusted") },
RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(_globalOptions.RejectedCertificateStorePath, "rejected") }
},
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = opts.SessionTimeoutMs },
TransportQuotas = new TransportQuotas { OperationTimeout = opts.OperationTimeoutMs }
};
await appConfig.ValidateAsync(ApplicationType.Client);
if (opts.AutoAcceptUntrustedCerts)
{
// This accepts ANY server certificate, defeating
// certificate trust enforcement. Surface a prominent warning so an operator
// who has opted in is aware of the man-in-the-middle exposure on the link.
_logger.LogWarning(
"OPC UA connection to {Endpoint} has AutoAcceptUntrustedCerts enabled — every " +
"server certificate is accepted unconditionally. This defeats Sign / " +
"SignAndEncrypt protection against a man-in-the-middle.", endpointUrl);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
}
// Discover endpoints from the server, pick the preferred security mode
var endpoint = await DiscoverEndpointAsync(endpointUrl, preferredSecurityMode,
token => DiscoverEndpointsAsync(appConfig, endpointUrl, token), cancellationToken);
// The server advertises its own base address, which is frequently unroutable from here
// (a 0.0.0.0 wildcard bind, or an internal container/NAT hostname). Swap it back to the
// host the operator actually reached, or the session channel dials an unreachable address.
RewriteEndpointHostForReachability(endpoint, endpointUrl);
var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
var sessionFactory = new DefaultSessionFactory();
#pragma warning restore CS0618
var userIdentity = BuildUserIdentity(opts.UserIdentity);
_session = await sessionFactory.CreateAsync(
appConfig, configuredEndpoint, false,
"ScadaBridge-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, null, cancellationToken);
// Detect server going offline via keep-alive failures
Interlocked.Exchange(ref _connectionLostFired, 0);
_session.KeepAlive += OnSessionKeepAlive;
// Store options for monitored item creation
_options = opts;
// Create the first data shard up front so a session is immediately usable;
// further shards are added on demand as the item budget fills.
await _applyGate.RunAsync(async () =>
{
_dataShards.Clear();
_itemShard.Clear();
_eventShard = null;
await CreateDataShardAsync(cancellationToken);
}, cancellationToken);
}
/// <summary>Item budget per subscription, guarding against a non-positive configured value.</summary>
private int ItemsPerShard => _options.MaxMonitoredItemsPerSubscription > 0
? _options.MaxMonitoredItemsPerSubscription
: 5000;
/// <summary>
/// Builds a Subscription from the current connection options. Callers must hold
/// <see cref="_applyGate"/>.
/// </summary>
private Subscription NewSubscription(string displayName) =>
new(_session!.DefaultSubscription)
{
DisplayName = displayName,
Priority = _options.SubscriptionPriority,
PublishingEnabled = true,
PublishingInterval = _options.PublishingIntervalMs,
KeepAliveCount = (uint)_options.KeepAliveCount,
LifetimeCount = (uint)_options.LifetimeCount,
MaxNotificationsPerPublish = (uint)_options.MaxNotificationsPerPublish
};
/// <summary>
/// Adds and creates a new data shard. Callers must hold <see cref="_applyGate"/>.
/// </summary>
private async Task<DataShard> CreateDataShardAsync(CancellationToken cancellationToken)
{
var suffix = _dataShards.Count == 0 ? string.Empty : $"-{_dataShards.Count + 1}";
var subscription = NewSubscription(_options.SubscriptionDisplayName + suffix);
_session!.AddSubscription(subscription);
await subscription.CreateAsync(cancellationToken);
var shard = new DataShard(subscription);
_dataShards.Add(shard);
_logger.LogDebug("OPC UA data shard {Shard} created (budget {Budget} items)",
subscription.DisplayName, ItemsPerShard);
return shard;
}
/// <summary>
/// Returns the dedicated Alarms &amp; Conditions event shard, creating it on first
/// use. Callers must hold <see cref="_applyGate"/>.
/// </summary>
private async Task<Subscription> GetOrCreateEventShardAsync(CancellationToken cancellationToken)
{
if (_eventShard != null)
return _eventShard;
var subscription = NewSubscription(_options.SubscriptionDisplayName + "-events");
_session!.AddSubscription(subscription);
await subscription.CreateAsync(cancellationToken);
_eventShard = subscription;
return subscription;
}
/// <summary>
/// Rewrites a discovered endpoint's host/port to match the reachable URL the operator
/// configured, when the server advertised a different (unroutable) one.
/// </summary>
/// <remarks>
/// An OPC UA server advertises its base address from its OWN configuration, not the route the
/// client took to reach it — so <c>GetEndpoints</c> routinely returns a wildcard bind
/// (<c>opc.tcp://0.0.0.0:4840/…</c>) or an internal container/NAT hostname the client cannot
/// dial. The OPC Foundation session opens its channel to <see cref="EndpointDescription.EndpointUrl"/>
/// verbatim, so a 0.0.0.0 advertisement resolves to the client's OWN loopback and the connect
/// fails. This swaps only the authority (host + port) to the reachable one, preserving the
/// advertised scheme and path (e.g. <c>/OtOpcUa</c>). Mirrors <c>CoreClientUtils.SelectEndpoint</c>.
/// A no-op when the advertised host/port is already reachable (so an opc-plc server that
/// advertises its real container name is left untouched).
/// </remarks>
/// <param name="endpoint">The discovered endpoint to rewrite in place; may be null.</param>
/// <param name="discoveryUrl">The reachable URL the operator configured.</param>
internal static void RewriteEndpointHostForReachability(EndpointDescription? endpoint, string discoveryUrl)
{
if (endpoint is null || string.IsNullOrWhiteSpace(endpoint.EndpointUrl))
return;
if (!Uri.TryCreate(discoveryUrl, UriKind.Absolute, out var reachable))
return;
if (!Uri.TryCreate(endpoint.EndpointUrl, UriKind.Absolute, out var advertised))
return;
// Already reachable — leave it exactly as advertised.
if (string.Equals(advertised.Host, reachable.Host, StringComparison.OrdinalIgnoreCase)
&& advertised.Port == reachable.Port)
return;
// An IPv6 literal must keep its brackets, or the reconstructed authority (`::1:4840`) is
// ambiguous and unparseable. Uri.Host may or may not include them depending on the scheme,
// so bracket only when missing (never double-wrap).
var host = reachable.Host;
if (reachable.HostNameType == UriHostNameType.IPv6 && !host.StartsWith('['))
host = $"[{host}]";
// opc.tcp has no default port known to Uri, so Uri.Port is -1 when the text omits one.
// Prefer the reachable URL's port, fall back to the advertised one, and emit no ":port"
// at all when neither carries one (rather than a malformed ":-1").
var port = reachable.Port >= 0 ? reachable.Port : advertised.Port;
var authority = port >= 0 ? $"{host}:{port}" : host;
// Authority-only advertisements parse to AbsolutePath "/" — drop it so we don't append a
// spurious trailing slash the server never advertised.
var path = advertised.AbsolutePath == "/" ? string.Empty : advertised.AbsolutePath;
endpoint.EndpointUrl = $"{advertised.Scheme}://{authority}{path}";
}
/// <summary>
/// Probes an OPC UA endpoint configuration WITHOUT persisting it or creating a
/// long-lived connection — connect, capture the server certificate if it is untrusted,
/// then disconnect. The probe is secure-by-default and READ-ONLY: it forces
/// <c>AutoAcceptUntrustedCertificates = false</c> and a validation hook that captures an
/// untrusted server certificate then REJECTS it (<c>e.Accept = false</c>). It never trusts
/// the certificate — trusting is a separate, later operator action. The session is always
/// disposed in a <c>finally</c>.
/// </summary>
/// <param name="config">The endpoint configuration to probe.</param>
/// <param name="globalOptions">Deployment-wide OPC UA application identity / cert-store paths.</param>
/// <param name="logger">Logger for diagnostics.</param>
/// <param name="timeout">Wall-clock budget for the whole probe (discovery + session create).</param>
/// <param name="ct">External cancellation token, linked with the timeout.</param>
/// <returns>A structured <see cref="VerifyEndpointResult"/> classifying the outcome.</returns>
public static async Task<VerifyEndpointResult> VerifyEndpointAsync(
OpcUaEndpointConfig config,
OpcUaGlobalOptions globalOptions,
ILogger logger,
TimeSpan timeout,
CancellationToken ct)
{
// Captured by the certificate-validation hook below. A non-null value here means
// the server presented an untrusted certificate; it dominates the outcome mapping.
X509Certificate2? capturedCert = null;
ISession? session = null;
Exception? failure = null;
var endpointUrl = string.IsNullOrWhiteSpace(config.EndpointUrl)
? "opc.tcp://localhost:4840"
: config.EndpointUrl;
var preferredSecurityMode = config.SecurityMode switch
{
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
_ => MessageSecurityMode.None
};
// Secure-by-default — force AutoAccept=false so an untrusted server cert is
// captured and rejected rather than silently accepted (defeating the whole probe).
var appConfig = new ApplicationConfiguration
{
ApplicationName = string.IsNullOrWhiteSpace(globalOptions.ApplicationName)
? "ScadaBridge-DCL"
: globalOptions.ApplicationName,
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
AutoAcceptUntrustedCertificates = false,
ApplicationCertificate = new CertificateIdentifier(),
TrustedIssuerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.TrustedIssuerStorePath, "issuers") },
TrustedPeerCertificates = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.TrustedPeerStorePath, "trusted") },
RejectedCertificateStore = new CertificateTrustList { StorePath = ResolveStorePath(globalOptions.RejectedCertificateStorePath, "rejected") }
},
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = config.SessionTimeoutMs },
TransportQuotas = new TransportQuotas { OperationTimeout = config.OperationTimeoutMs }
};
// Capture the untrusted server cert, then REJECT it (e.Accept = false). The
// validator runs on the SDK's connect thread; copying the cert is the only state we
// keep. Never accept — this probe must not trust anything.
appConfig.CertificateValidator.CertificateValidation += (_, e) =>
{
try
{
// Copy into a stable instance so disposing the SDK's chain doesn't invalidate it.
capturedCert = X509CertificateLoader.LoadCertificate(e.Certificate.RawData);
}
catch
{
// Best-effort capture: fall back to the original reference if the copy fails.
capturedCert = e.Certificate;
}
e.Accept = false;
};
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
linkedCts.CancelAfter(timeout);
try
{
await appConfig.ValidateAsync(ApplicationType.Client);
// Discover endpoints, pick the preferred security mode (same logic as ConnectAsync).
// Bounded by the probe's own budget (linkedCts), not just the SDK operation timeout.
var endpoint = await DiscoverEndpointAsync(endpointUrl, preferredSecurityMode,
token => DiscoverEndpointsAsync(appConfig, endpointUrl, token), linkedCts.Token);
// Same reachability rewrite as ConnectAsync — a probe against a server advertising a
// 0.0.0.0 / NAT base address must dial the reachable host, not the advertised one.
RewriteEndpointHostForReachability(endpoint, endpointUrl);
var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
#pragma warning disable CS0618 // Allow obsolete DefaultSessionFactory constructor for compatibility
var sessionFactory = new DefaultSessionFactory();
#pragma warning restore CS0618
var userIdentity = BuildUserIdentity(config.UserIdentity is { } ui
? new OpcUaUserIdentityOptions(
ui.TokenType.ToString(), ui.Username, ui.Password,
ui.CertificatePath, ui.CertificatePassword)
: null);
session = await sessionFactory.CreateAsync(
appConfig, configuredEndpoint, false,
"ScadaBridge-DCL-Verify", (uint)config.SessionTimeoutMs,
userIdentity, null, linkedCts.Token);
}
catch (Exception ex)
{
// OperationCanceledException from the linked CTS firing on timeout is mapped to
// VerifyFailureKind.Timeout inside MapVerifyOutcome.
failure = ex;
logger.LogDebug(ex, "OPC UA verify of {Endpoint} failed.", endpointUrl);
}
finally
{
// ALWAYS dispose the probe session — never leave a connection open.
if (session != null)
{
try { await session.CloseAsync(CancellationToken.None); }
catch (Exception ex) { logger.LogDebug(ex, "OPC UA verify session close failed (ignored)."); }
session.Dispose();
}
}
return MapVerifyOutcome(failure, capturedCert);
}
/// <summary>
/// Pure mapping of a probe outcome — an optional exception plus an optionally
/// captured untrusted server certificate — to a <see cref="VerifyEndpointResult"/>.
/// Factored out so the classification is unit-testable WITHOUT a live OPC UA server.
/// Precedence: a captured certificate ALWAYS yields
/// <see cref="VerifyFailureKind.UntrustedCertificate"/>; otherwise the exception is
/// classified; null exception + null cert means the session was created (success).
/// </summary>
/// <param name="failure">The exception thrown during the probe, or null on success.</param>
/// <param name="capturedCert">The untrusted server certificate captured by the validation hook, or null.</param>
/// <returns>The classified verification result.</returns>
internal static VerifyEndpointResult MapVerifyOutcome(Exception? failure, X509Certificate2? capturedCert)
{
// An untrusted server certificate dominates — regardless of how the connect failed,
// this is the actionable case (the operator may choose to trust it later).
if (capturedCert != null)
{
var info = new ServerCertInfo(
capturedCert.Thumbprint,
capturedCert.Subject,
capturedCert.Issuer,
capturedCert.NotBefore.ToUniversalTime(),
capturedCert.NotAfter.ToUniversalTime(),
Convert.ToBase64String(capturedCert.RawData));
return new VerifyEndpointResult(
false, VerifyFailureKind.UntrustedCertificate,
"The server certificate is not trusted by this site.", info);
}
if (failure is null)
return new VerifyEndpointResult(true, null, null, null);
// Timeout / cancellation (the linked CTS fired, or the SDK reported a request timeout).
if (failure is TimeoutException or OperationCanceledException)
return new VerifyEndpointResult(false, VerifyFailureKind.Timeout, failure.Message, null);
if (failure is ServiceResultException sre)
{
// A socket cause wrapped inside the SDK exception means the host is unreachable.
if (HasSocketCause(sre))
return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, sre.Message, null);
switch (sre.StatusCode)
{
case StatusCodes.BadRequestTimeout:
case StatusCodes.BadTimeout:
return new VerifyEndpointResult(false, VerifyFailureKind.Timeout, sre.Message, null);
case StatusCodes.BadUserAccessDenied:
case StatusCodes.BadIdentityTokenRejected:
case StatusCodes.BadIdentityTokenInvalid:
return new VerifyEndpointResult(false, VerifyFailureKind.AuthFailed, sre.Message, null);
case StatusCodes.BadConnectionRejected:
case StatusCodes.BadNotConnected:
case StatusCodes.BadConnectionClosed:
case StatusCodes.BadNoCommunication:
case StatusCodes.BadServerNotConnected:
return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, sre.Message, null);
default:
return new VerifyEndpointResult(false, VerifyFailureKind.ServerError, sre.Message, null);
}
}
// A bare socket failure (DNS / connection refused) before the SDK wrapped it.
if (HasSocketCause(failure))
return new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, failure.Message, null);
return new VerifyEndpointResult(false, VerifyFailureKind.ServerError, failure.Message, null);
}
/// <summary>
/// Walks the exception's <c>InnerException</c> chain looking for a
/// <see cref="System.Net.Sockets.SocketException"/> — the signature of a DNS-resolution
/// or connection-refused failure that means the endpoint host is unreachable.
/// </summary>
private static bool HasSocketCause(Exception ex)
{
for (var cur = ex; cur != null; cur = cur.InnerException)
{
if (cur is System.Net.Sockets.SocketException)
return true;
}
return false;
}
/// <inheritdoc />
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
await _applyGate.RunAsync(async () =>
{
foreach (var shard in _dataShards)
await shard.Subscription.DeleteAsync(true);
_dataShards.Clear();
_itemShard.Clear();
if (_eventShard != null)
{
await _eventShard.DeleteAsync(true);
_eventShard = null;
}
}, cancellationToken);
if (_session != null)
{
_session.KeepAlive -= OnSessionKeepAlive;
await _session.CloseAsync(cancellationToken);
_session = null;
}
_monitoredItems.Clear();
_callbacks.Clear();
_alarmItems.Clear();
_alarmInRefresh.Clear();
_alarmSnapshotIds.Clear();
_alarmLastState.Clear();
}
/// <inheritdoc />
public async Task<string> CreateSubscriptionAsync(
string nodeId, Action<string, object?, DateTime, uint> onValueChanged,
CancellationToken cancellationToken = default)
{
// Batch-of-one delegation: the single-node entry point is kept (heartbeat monitor,
// interactive paths) but shares the batch implementation so there is exactly one
// placement / ApplyChanges code path. A per-item failure throws here — preserving
// the caller's historical "create throws on failure" contract AND the exception
// TYPE, so DataConnectionActor.IsConnectionLevelFailure still classifies a
// resolution failure as a tag-resolution failure rather than a connection fault.
var (outcomes, errors) = await CreateSubscriptionsCoreAsync(
[nodeId], onValueChanged, cancellationToken);
var outcome = outcomes[0];
if (outcome.Success)
return outcome.SubscriptionHandle!;
if (errors[0] is { } captured)
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(captured).Throw();
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown,
outcome.Error ?? $"Monitored item for '{nodeId}' could not be created.");
}
/// <inheritdoc />
public async Task<IReadOnlyList<OpcUaSubscribeOutcome>> CreateSubscriptionsAsync(
IReadOnlyList<string> nodeIds,
Action<string, object?, DateTime, uint> onValueChanged,
CancellationToken cancellationToken = default)
{
var (outcomes, _) = await CreateSubscriptionsCoreAsync(nodeIds, onValueChanged, cancellationToken);
return outcomes;
}
/// <summary>
/// Creates monitored items for every requested node with ONE ApplyChanges per touched
/// shard — the core of the batch subscribe seam. Node-id resolution runs outside the
/// apply lock (it is pure over the session's namespace table); placement, AddItem and
/// ApplyChanges run under it. Returns per-node outcomes plus the captured exception
/// (when any) per node, so the single-node entry point can rethrow the original.
/// </summary>
private async Task<(OpcUaSubscribeOutcome[] Outcomes, Exception?[] Errors)> CreateSubscriptionsCoreAsync(
IReadOnlyList<string> nodeIds,
Action<string, object?, DateTime, uint> onValueChanged,
CancellationToken cancellationToken)
{
var session = _session;
if (session == null)
throw new InvalidOperationException("Not connected.");
var outcomes = new OpcUaSubscribeOutcome[nodeIds.Count];
var errors = new Exception?[nodeIds.Count];
// Resolve first: a bad node id is a per-tag fault and must not abort the batch.
var resolved = new List<(int Index, string NodeId, NodeId Resolved)>(nodeIds.Count);
for (var i = 0; i < nodeIds.Count; i++)
{
try
{
resolved.Add((i, nodeIds[i], OpcUaNodeReference.Resolve(nodeIds[i], session.NamespaceUris)));
}
catch (Exception ex)
{
outcomes[i] = new OpcUaSubscribeOutcome(nodeIds[i], false, null, ex.Message);
errors[i] = ex;
}
}
if (resolved.Count == 0)
return (outcomes, errors);
return await _applyGate.RunAsync(async () =>
{
var created = new List<(int Index, string NodeId, string Handle, MonitoredItem Item)>(resolved.Count);
var touched = new List<DataShard>();
try
{
await PlaceAndApplyAsync(resolved, onValueChanged, created, touched, cancellationToken);
}
catch
{
// Anything thrown from placement or ApplyChanges (a dead session is the common
// one) leaves every item created so far registered in _callbacks / _itemShard and
// counted against its shard's budget, with nothing to ever release them: the
// actor sees the throw as a whole-chunk fault and re-issues the SAME batch on its
// backoff timer, so each probe grew the bookkeeping again. Unwind before propagating.
UnwindCreatedItems(created);
throw;
}
foreach (var (index, nodeId, handle, item) in created)
{
var error = item.Status?.Error;
if (error != null && ServiceResult.IsBad(error))
{
// Per-item create failure (unknown node, access denied): release the
// half-created item so the shard budget stays honest, and report a
// failed row. The actor treats it as an unresolved tag and retries it
// on the backoff timer.
ReleaseFailedItem(handle, item);
outcomes[index] = new OpcUaSubscribeOutcome(nodeId, false, null, error.ToString());
}
else
{
_monitoredItems[handle] = item;
outcomes[index] = new OpcUaSubscribeOutcome(nodeId, true, handle, null);
}
}
return (outcomes, errors);
}, cancellationToken);
}
/// <summary>
/// Places every resolved node on a data shard and issues ONE ApplyChanges per touched shard.
/// Callers must hold <see cref="_applyGate"/>. <paramref name="created"/> and
/// <paramref name="touched"/> are filled as the work proceeds so a throw can be unwound by
/// <see cref="UnwindCreatedItems"/>.
/// </summary>
private async Task PlaceAndApplyAsync(
List<(int Index, string NodeId, NodeId Resolved)> resolved,
Action<string, object?, DateTime, uint> onValueChanged,
List<(int Index, string NodeId, string Handle, MonitoredItem Item)> created,
List<DataShard> touched,
CancellationToken cancellationToken)
{
// Plan once for this chunk while the apply gate keeps shard occupancy stable.
var placement = MonitoredItemShardPlanner.Plan(
_dataShards.Select(s => s.ItemCount).ToArray(), ItemsPerShard, resolved.Count);
for (var itemIndex = 0; itemIndex < resolved.Count; itemIndex++)
{
var (index, nodeId, startNode) = resolved[itemIndex];
var shardIndex = placement[itemIndex];
var shard = shardIndex < _dataShards.Count
? _dataShards[shardIndex]
: await CreateDataShardAsync(cancellationToken);
var handle = Guid.NewGuid().ToString();
var monitoredItem = new MonitoredItem(shard.Subscription.DefaultItem)
{
DisplayName = nodeId,
StartNodeId = startNode,
AttributeId = Attributes.Value,
SamplingInterval = _options.SamplingIntervalMs,
QueueSize = (uint)_options.QueueSize,
DiscardOldest = _options.DiscardOldest,
Filter = BuildDataChangeFilter(_options.Deadband)
};
_callbacks[handle] = onValueChanged;
monitoredItem.Notification += (_, e) =>
{
if (e.NotificationValue is MonitoredItemNotification notification)
{
var value = notification.Value?.Value;
var timestamp = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
var statusCode = notification.Value?.StatusCode.Code ?? 0;
if (_callbacks.TryGetValue(handle, out var cb))
{
cb(nodeId, value, timestamp, statusCode);
}
}
};
shard.Subscription.AddItem(monitoredItem);
shard.ItemCount++;
_itemShard[handle] = shard.Subscription;
created.Add((index, nodeId, handle, monitoredItem));
if (!touched.Contains(shard))
touched.Add(shard);
}
// ONE ApplyChanges per touched shard — the whole point of the batch seam.
foreach (var shard in touched)
await shard.Subscription.ApplyChangesAsync(cancellationToken);
}
/// <summary>
/// Rolls back the monitored items a FAILED batch had already placed, so a throw leaves no
/// residue behind: no <c>_callbacks</c> entry (a leak that also keeps a dead callback
/// reachable from the SDK's publish thread), no <c>_itemShard</c> entry, no phantom shard
/// occupancy, and no item left on the shard's local item list. Callers must hold
/// <see cref="_applyGate"/>.
///
/// <para>
/// Best-effort and exception-safe by design: it runs on the failure path, so one item that
/// cannot be released must not stop the rest from being released and must not replace the
/// original fault. No ApplyChanges is re-issued — the session that just threw is generally
/// dead, and where a partial apply DID reach the server the items it created are reclaimed
/// when the shard and session are torn down on the reconnect that the connection-level
/// classification of that fault now drives.
/// </para>
/// </summary>
private void UnwindCreatedItems(
List<(int Index, string NodeId, string Handle, MonitoredItem Item)> created)
{
if (created.Count == 0)
return;
foreach (var (_, nodeId, handle, item) in created)
{
try
{
ReleaseFailedItem(handle, item);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"OPC UA batch subscribe rollback could not release the monitored item for {NodeId}",
nodeId);
}
}
_logger.LogWarning(
"OPC UA batch subscribe failed — rolled back {Count} partially created monitored item(s)",
created.Count);
}
/// <summary>
/// Drops a monitored item that failed to create from the shard it was placed on.
/// Callers must hold <see cref="_applyGate"/>. No ApplyChanges is issued: the server
/// never created the item, so removing it locally keeps the budget honest.
/// </summary>
private void ReleaseFailedItem(string handle, MonitoredItem item)
{
_callbacks.TryRemove(handle, out _);
if (!_itemShard.TryRemove(handle, out var subscription))
return;
subscription.RemoveItem(item);
var shard = _dataShards.FirstOrDefault(s => ReferenceEquals(s.Subscription, subscription));
if (shard is { ItemCount: > 0 })
shard.ItemCount--;
}
/// <inheritdoc />
public Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
=> RemoveSubscriptionsAsync([subscriptionHandle], cancellationToken);
/// <inheritdoc />
public async Task RemoveSubscriptionsAsync(
IReadOnlyList<string> subscriptionHandles, CancellationToken cancellationToken = default)
{
if (subscriptionHandles.Count == 0)
return;
await _applyGate.RunAsync(async () =>
{
var touched = new List<DataShard>();
foreach (var handle in subscriptionHandles)
{
if (!_monitoredItems.TryGetValue(handle, out var item))
continue;
if (!_itemShard.TryGetValue(handle, out var subscription))
continue;
subscription.RemoveItem(item);
_monitoredItems.TryRemove(handle, out _);
_callbacks.TryRemove(handle, out _);
_itemShard.TryRemove(handle, out _);
var shard = _dataShards.FirstOrDefault(s => ReferenceEquals(s.Subscription, subscription));
if (shard == null)
continue;
if (shard.ItemCount > 0)
shard.ItemCount--;
if (!touched.Contains(shard))
touched.Add(shard);
}
// ONE ApplyChanges per touched shard, then drop the shards that emptied.
foreach (var shard in touched)
{
await shard.Subscription.ApplyChangesAsync(cancellationToken);
if (shard.ItemCount != 0)
continue;
_dataShards.Remove(shard);
try
{
await shard.Subscription.DeleteAsync(true);
}
catch (Exception ex)
{
// Best-effort: an empty shard the server already dropped must not fail
// the unsubscribe path.
_logger.LogDebug(ex, "Deleting emptied OPC UA data shard {Shard} failed (ignored).",
shard.Subscription.DisplayName);
}
}
}, cancellationToken);
}
// ── Native alarm (Alarms & Conditions) subscription ──
// Behavioral correctness verified against a live A&C server; only
// the OpcUaAlarmMapper value→state logic is unit-tested.
// Fixed select-clause order; parsed by index in HandleAlarmEvent.
private static readonly string[] AlarmStateFields =
["EventType", "SourceNode", "SourceName", "Time", "Message", "Severity"];
/// <inheritdoc />
public async Task<string> CreateAlarmSubscriptionAsync(
string? sourceNodeId, string? conditionFilter,
Action<NativeAlarmTransition> onTransition, CancellationToken cancellationToken = default)
{
if (_session == null)
throw new InvalidOperationException("Not connected.");
var handle = Guid.NewGuid().ToString();
_alarmInRefresh[handle] = false;
_alarmLastState[handle] = new Dictionary<string, (bool, bool)>(StringComparer.Ordinal);
var startNode = string.IsNullOrEmpty(sourceNodeId)
? ObjectIds.Server
: OpcUaNodeReference.Resolve(sourceNodeId, _session.NamespaceUris);
// A&C event items live on their own shard (see _eventShard): ConditionRefresh
// needs one subscription id to target, and QueueSize:1000 event items must not
// occupy data-shard capacity.
var eventShard = await _applyGate.RunAsync(
() => GetOrCreateEventShardAsync(cancellationToken), cancellationToken);
var item = new MonitoredItem(eventShard.DefaultItem)
{
DisplayName = $"alarm:{sourceNodeId ?? "Server"}",
StartNodeId = startNode,
AttributeId = Attributes.EventNotifier,
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = 0,
QueueSize = 1000,
// Server-side WhereClause is a bandwidth optimisation only — the
// authoritative condition-type gate lives in DataConnectionActor.
Filter = BuildAlarmEventFilter(AlarmConditionFilter.Parse(conditionFilter))
};
item.Notification += (_, e) =>
{
if (e.NotificationValue is EventFieldList efl)
// sourceNodeId is the binding this feed was subscribed under; every
// transition on the feed is routed under it verbatim.
HandleAlarmEvent(handle, sourceNodeId, efl, onTransition);
};
await _applyGate.RunAsync(async () =>
{
eventShard.AddItem(item);
await eventShard.ApplyChangesAsync(cancellationToken);
}, cancellationToken);
_alarmItems[handle] = item;
// Replay currently-active conditions as a Snapshot…SnapshotComplete sequence.
await TriggerConditionRefreshAsync(handle, eventShard, cancellationToken);
return handle;
}
/// <inheritdoc />
public async Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
{
if (_alarmItems.TryRemove(subscriptionHandle, out var item))
{
await _applyGate.RunAsync(async () =>
{
if (_eventShard != null)
{
_eventShard.RemoveItem(item);
await _eventShard.ApplyChangesAsync(cancellationToken);
}
}, cancellationToken);
}
_alarmInRefresh.TryRemove(subscriptionHandle, out _);
_alarmSnapshotIds.TryRemove(subscriptionHandle, out _);
_alarmLastState.TryRemove(subscriptionHandle, out _);
}
/// <summary>
/// Maps the standard OPC UA Alarms &amp; Conditions type names (case-insensitive)
/// to their well-known <see cref="ObjectTypeIds"/> NodeIds, for building the
/// optional server-side WhereClause. Only standard types appear
/// here; vendor/custom type names cannot be mapped without browsing the server
/// type tree, so they are handled by the client-side gate alone.
/// <para>
/// Single source of truth for both directions: <see cref="ConditionTypeNamesById"/>
/// is derived from this map, so the friendly-name and NodeId sides cannot drift.
/// </para>
/// </summary>
internal static readonly IReadOnlyDictionary<string, NodeId> KnownConditionTypeIds =
new Dictionary<string, NodeId>(StringComparer.OrdinalIgnoreCase)
{
["ConditionType"] = ObjectTypeIds.ConditionType,
["AcknowledgeableConditionType"] = ObjectTypeIds.AcknowledgeableConditionType,
["AlarmConditionType"] = ObjectTypeIds.AlarmConditionType,
["LimitAlarmType"] = ObjectTypeIds.LimitAlarmType,
["ExclusiveLimitAlarmType"] = ObjectTypeIds.ExclusiveLimitAlarmType,
["NonExclusiveLimitAlarmType"] = ObjectTypeIds.NonExclusiveLimitAlarmType,
["ExclusiveLevelAlarmType"] = ObjectTypeIds.ExclusiveLevelAlarmType,
["NonExclusiveLevelAlarmType"] = ObjectTypeIds.NonExclusiveLevelAlarmType,
["ExclusiveDeviationAlarmType"] = ObjectTypeIds.ExclusiveDeviationAlarmType,
["NonExclusiveDeviationAlarmType"] = ObjectTypeIds.NonExclusiveDeviationAlarmType,
["ExclusiveRateOfChangeAlarmType"] = ObjectTypeIds.ExclusiveRateOfChangeAlarmType,
["NonExclusiveRateOfChangeAlarmType"] = ObjectTypeIds.NonExclusiveRateOfChangeAlarmType,
["DiscreteAlarmType"] = ObjectTypeIds.DiscreteAlarmType,
["OffNormalAlarmType"] = ObjectTypeIds.OffNormalAlarmType,
["SystemOffNormalAlarmType"] = ObjectTypeIds.SystemOffNormalAlarmType,
["TripAlarmType"] = ObjectTypeIds.TripAlarmType,
["DiscrepancyAlarmType"] = ObjectTypeIds.DiscrepancyAlarmType,
["InstrumentDiagnosticAlarmType"] = ObjectTypeIds.InstrumentDiagnosticAlarmType,
["SystemDiagnosticAlarmType"] = ObjectTypeIds.SystemDiagnosticAlarmType,
["CertificateExpirationAlarmType"] = ObjectTypeIds.CertificateExpirationAlarmType,
};
/// <summary>
/// Inverse of <see cref="KnownConditionTypeIds"/> (NodeId → friendly name), derived
/// from it so the two cannot drift. Used by <see cref="ResolveAlarmTypeName"/>
/// to translate the event-type NodeId an OPC UA server sends back into the friendly
/// type name the conditionFilter gate and server-side WhereClause both key off.
/// </summary>
private static readonly IReadOnlyDictionary<NodeId, string> ConditionTypeNamesById =
KnownConditionTypeIds.ToDictionary(kv => kv.Value, kv => kv.Key);
/// <summary>
/// Resolves an event-type <see cref="NodeId"/> to the friendly condition-type name the
/// <c>conditionFilter</c> gate (and the server-side WhereClause) use.
///
/// <para>
/// Standard A&amp;C types are returned as their friendly name (e.g. <c>i=9341</c> →
/// <c>"ExclusiveLevelAlarmType"</c>) so the client-side gate — which compares against
/// the friendly names in <see cref="KnownConditionTypeIds"/> — actually matches the
/// events the server delivers. Vendor/custom subtypes that are not in the map fall back
/// to the NodeId string; that is consistent because the WhereClause is likewise omitted
/// for unmapped names, so such a filter can only be expressed (and matched) as the NodeId
/// string. A <c>null</c> event type yields the empty string.
/// </para>
/// </summary>
/// <param name="eventType">The event-type NodeId from the A&amp;C notification, or <c>null</c>.</param>
/// <returns>The friendly type name when known; otherwise the NodeId string (or "" when null).</returns>
internal static string ResolveAlarmTypeName(NodeId? eventType)
{
if (eventType is null)
return "";
return ConditionTypeNamesById.TryGetValue(eventType, out var friendly)
? friendly
: eventType.ToString();
}
/// <summary>
/// Builds the event filter selecting the base event fields plus the
/// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror,
/// and — when <paramref name="conditionFilter"/> is non-empty and every requested
/// type maps to a standard A&amp;C type — a server-side <see cref="ContentFilter"/>
/// WhereClause (OfType, OR'd) as a bandwidth optimisation.
///
/// <para>
/// Conservative by design: if <em>any</em> requested type name cannot be mapped to
/// a standard <see cref="ObjectTypeIds"/> NodeId, the WhereClause is omitted entirely
/// rather than partially applied — a partial server-side filter would silently drop
/// the unmapped types' events, and the server cannot send what it filtered out. The
/// client-side gate in DataConnectionActor enforces the full filter regardless, so
/// omitting the WhereClause only forgoes the bandwidth saving, never correctness.
/// </para>
/// </summary>
/// <param name="conditionFilter">The parsed condition-type filter (allow-all when empty).</param>
/// <returns>The configured <see cref="EventFilter"/>.</returns>
internal static EventFilter BuildAlarmEventFilter(AlarmConditionFilter conditionFilter)
{
var filter = new EventFilter();
foreach (var name in AlarmStateFields)
filter.SelectClauses.Add(SelectField(ObjectTypeIds.BaseEventType, name));
// Two-state sub-condition /Id booleans + shelving current-state + identity.
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "Id")); // 6
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "AckedState", "Id")); // 7
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AcknowledgeableConditionType, "ConfirmedState", "Id"));// 8
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "SuppressedState", "Id")); // 9
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ShelvingState", "CurrentState"));// 10
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName")); // 11
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment")); // 12
// APPENDED fields (indices 13+): optional — only present on specific derived types.
// Guard all reads with fields.Count > N so base-ConditionType events still process.
// 13: AlarmConditionType/ActiveState/TransitionTime — the UTC instant the active-state
// last flipped to TRUE. Mapped to OriginalRaiseTime; absent on non-AlarmCondition
// events (ConditionType base events rarely carry it). CAVEAT: during a
// ConditionRefresh replay the server MAY re-stamp this to the current/restart time
// rather than the historical raise instant (OPC UA Part 9 §5.5.2 makes it advisory),
// so a snapshot-derived OriginalRaiseTime can look like the refresh time — it is
// display-only and not treated as authoritative.
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "TransitionTime")); // 13
// 14–17: LimitAlarmType limit thresholds — configuration-time set-points exposed as
// event fields by LimitAlarmType and all its subtypes (Exclusive/NonExclusive
// Level/Deviation/RateOfChange). Absent on non-limit alarm types (e.g. discrete,
// off-normal) — guarded by fields.Count > N below.
filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighHighLimit")); // 14
filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighLimit")); // 15
filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLimit")); // 16
filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLowLimit")); // 17
// 18: AcknowledgeableConditionType/AckedState/TransitionTime — the UTC instant the
// acked-state last flipped. When the condition is currently ACKED that instant IS
// the moment of acknowledgement, so it is the true source ack time mapped to
// NativeAlarmTransition.AckTime (MES alarm-status API §6.4). Optional — absent on
// base ConditionType events and on servers that do not expose it, in which case
// OpcUaAlarmMapper.DeriveAckTime falls back to the event's own Time field. Same
// ConditionRefresh caveat as index 13: a replayed snapshot may re-stamp it.
filter.SelectClauses.Add(
SelectField(ObjectTypeIds.AcknowledgeableConditionType, "AckedState", "TransitionTime")); // 18
// UNAVAILABLE via standard OPC UA A&C event fields (documented here so future
// maintainers know these were considered, not overlooked):
// Category — not a standard event field; server-specific extensions only.
// Description — NativeAlarmTransition.Description is a static template description;
// OPC UA events carry dynamic Message text (index 4, mapped) but no
// static template description in the notification, so this stays empty.
// OperatorUser — not available on the standard ConditionRefresh replay stream;
// present on Acknowledge/Confirm method call results, but those do
// not flow through the monitored-item subscription.
// CurrentValue — the live process variable value is NOT a standard A&C event field;
// it would require a separate data subscription on the source node.
ApplyServerSideTypeWhereClause(filter, conditionFilter);
return filter;
}
/// <summary>
/// Attaches an OfType(-OR'd) WhereClause to <paramref name="filter"/> when every
/// requested condition type maps to a standard A&amp;C type NodeId; otherwise leaves
/// the WhereClause empty (see <see cref="BuildAlarmEventFilter"/> rationale).
/// </summary>
private static void ApplyServerSideTypeWhereClause(EventFilter filter, AlarmConditionFilter conditionFilter)
{
if (conditionFilter.IsEmpty)
return;
var typeIds = new List<NodeId>();
foreach (var name in conditionFilter.Names)
{
if (!KnownConditionTypeIds.TryGetValue(name, out var id))
return; // unmapped type → omit the WhereClause entirely (client gate covers it)
typeIds.Add(id);
}
if (typeIds.Count == 0)
return;
var where = filter.WhereClause;
if (typeIds.Count == 1)
{
where.Push(FilterOperator.OfType, typeIds[0]);
return;
}
// OR together each OfType element so an event of ANY listed type passes.
var element = where.Push(FilterOperator.OfType, typeIds[0]);
for (var i = 1; i < typeIds.Count; i++)
{
var next = where.Push(FilterOperator.OfType, typeIds[i]);
element = where.Push(FilterOperator.Or, element, next);
}
}
private static SimpleAttributeOperand SelectField(NodeId typeDefinitionId, params string[] browse)
{
var path = new QualifiedNameCollection();
foreach (var b in browse)
path.Add(new QualifiedName(b));
return new SimpleAttributeOperand
{
TypeDefinitionId = typeDefinitionId,
BrowsePath = path,
AttributeId = Attributes.Value
};
}
private async Task TriggerConditionRefreshAsync(
string handle, Subscription eventShard, CancellationToken cancellationToken)
{
try
{
// ConditionRefresh replays active conditions; RefreshStart/End events
// bracket the replay so HandleAlarmEvent can mark them Snapshot. It targets
// the EVENT shard's subscription id — the shard every A&C item is pinned to.
await _session!.CallAsync(
ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh,
cancellationToken, eventShard.Id);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ConditionRefresh failed for alarm subscription {Handle}", handle);
}
}
private void HandleAlarmEvent(
string handle, string? subscriptionSourceReference, EventFieldList efl,
Action<NativeAlarmTransition> onTransition)
{
var fields = efl.EventFields;
if (fields == null || fields.Count < AlarmStateFields.Length)
return;
var eventType = fields[0].Value as NodeId;
// RefreshStart/End bracket the snapshot replay.
if (eventType == ObjectTypeIds.RefreshStartEventType)
{
_alarmInRefresh[handle] = true;
var startEpoch = Guid.NewGuid();
_alarmSnapshotIds[handle] = startEpoch;
onTransition(SnapshotBoundary(AlarmTransitionKind.SnapshotStart, subscriptionSourceReference, startEpoch));
return;
}
if (eventType == ObjectTypeIds.RefreshEndEventType)
{
_alarmInRefresh[handle] = false;
if (_alarmSnapshotIds.TryRemove(handle, out var endEpoch))
onTransition(SnapshotBoundary(AlarmTransitionKind.SnapshotComplete, subscriptionSourceReference, endEpoch));
return;
}
// Field layout (AlarmStateFields): [1]=SourceNode (NodeId), [2]=SourceName (string).
// The routing identity is the binding this feed was subscribed under (not the
// event's name), so DataConnectionActor's NodeId-keyed routing matches — see
// OpcUaAlarmMapper.BuildIdentity. SourceName seeds the readable per-condition
// key; fall back to the SourceNode NodeId string only when it is absent.
var sourceName = fields[2].Value as string;
if (string.IsNullOrEmpty(sourceName))
sourceName = (fields[1].Value as NodeId)?.ToString() ?? "";
var conditionName = fields.Count > 11 ? fields[11].Value as string : null;
var (sourceRef, sourceObjectRef) =
OpcUaAlarmMapper.BuildIdentity(subscriptionSourceReference, sourceName, conditionName);
if (string.IsNullOrEmpty(sourceRef))
return; // not a condition event we can key
var time = fields[3].Value is DateTime dt ? new DateTimeOffset(dt, TimeSpan.Zero) : DateTimeOffset.UtcNow;
var message = (fields[4].Value as LocalizedText)?.Text ?? "";
var severity = fields[5].Value is null ? 0 : Convert.ToInt32(fields[5].Value);
var active = fields.Count > 6 && fields[6].Value is bool a && a;
var acked = fields.Count <= 7 || fields[7].Value is not bool ak || ak; // default acked when absent
bool? confirmed = fields.Count > 8 && fields[8].Value is bool cf ? cf : null;
var suppressed = fields.Count > 9 && fields[9].Value is bool sp && sp;
var shelve = OpcUaAlarmMapper.MapShelve(fields.Count > 10 ? (fields[10].Value as LocalizedText)?.Text : null);
var comment = fields.Count > 12 ? (fields[12].Value as LocalizedText)?.Text ?? "" : "";
// Index 13: ActiveState/TransitionTime → OriginalRaiseTime (when active-state last
// transitioned to TRUE). Absent on non-AlarmCondition events → guard + null fallback.
DateTimeOffset? originalRaiseTime = null;
if (fields.Count > 13 && fields[13].Value is DateTime activeTransitionTime)
// OPC UA mandates UTC for DateTime fields; a TimeSpan.Zero offset treats an
// Unspecified Kind as UTC (consistent with the Time→TransitionTime mapping above).
originalRaiseTime = new DateTimeOffset(activeTransitionTime, TimeSpan.Zero);
// Indices 14–17: LimitAlarmType set-point thresholds (HighHighLimit/HighLimit/
// LowLimit/LowLowLimit). Absent on non-limit alarm types → null when missing.
// Pick the first non-null value in priority order (HiHi > Hi > Lo > LoLo) as a
// display-only representative limit; the caller is responsible for interpreting
// which limit is active using AlarmTypeName or ConditionName.
var limitValue = OpcUaAlarmMapper.PickLimitValue(
fields.Count > 14 ? fields[14].Value : null,
fields.Count > 15 ? fields[15].Value : null,
fields.Count > 16 ? fields[16].Value : null,
fields.Count > 17 ? fields[17].Value : null);
// Index 18: AckedState/TransitionTime → the true source ack instant when the condition
// is currently acked. Absent on non-acknowledgeable events / servers that omit it →
// guard + null fallback, which DeriveAckTime resolves to the event time below.
DateTimeOffset? sourceAckTime = null;
if (fields.Count > 18 && fields[18].Value is DateTime ackTransitionTime)
// OPC UA mandates UTC for DateTime fields; TimeSpan.Zero treats an Unspecified
// Kind as UTC (consistent with the Time and ActiveState/TransitionTime mappings).
sourceAckTime = new DateTimeOffset(ackTransitionTime, TimeSpan.Zero);
var inRefresh = _alarmInRefresh.GetValueOrDefault(handle);
var lastState = _alarmLastState.GetValueOrDefault(handle);
var (prevActive, prevAcked) = lastState != null && lastState.TryGetValue(sourceRef, out var prev) ? prev : (false, true);
var kind = inRefresh
? AlarmTransitionKind.Snapshot
: OpcUaAlarmMapper.DeriveKind(prevAcked, acked, prevActive, active);
if (lastState != null)
{
// Inactive + acknowledged IS the default prior state above, so forgetting it changes
// no derived kind — and keeps the map bounded on servers whose condition names carry
// a per-occurrence component (batch, lot, counter), where every occurrence is a new
// key for the life of the session (review 2026-10-21 02/L3; mirrors the actor's
// CacheAlarmTransition removal rule).
if (!active && acked)
lastState.Remove(sourceRef);
else
lastState[sourceRef] = (active, acked);
}
onTransition(new NativeAlarmTransition(
SourceReference: sourceRef,
SourceObjectReference: sourceObjectRef,
// Resolve the event-type NodeId (e.g. "i=9341") to the friendly type name
// the conditionFilter gate keys off; NodeId-string for custom types.
AlarmTypeName: ResolveAlarmTypeName(eventType),
Kind: kind,
Condition: OpcUaAlarmMapper.BuildCondition(active, acked, confirmed, shelve, suppressed, severity),
// UNAVAILABLE via standard OPC UA A&C event fields — see BuildAlarmEventFilter comments.
Category: "",
Description: "",
Message: message,
// UNAVAILABLE: OperatorUser not on refresh stream — see BuildAlarmEventFilter comments.
OperatorUser: "",
OperatorComment: comment,
OriginalRaiseTime: originalRaiseTime,
TransitionTime: time,
// UNAVAILABLE: CurrentValue not a standard A&C event field — see BuildAlarmEventFilter.
CurrentValue: "",
LimitValue: limitValue,
// MES alarm-status API §6.4: true source ack instant (index 18) when supplied,
// else the observed event time; null unless the condition is active AND acked.
AckTime: OpcUaAlarmMapper.DeriveAckTime(active, acked, sourceAckTime, time),
SnapshotSourceReference: inRefresh ? subscriptionSourceReference ?? string.Empty : null,
SnapshotId: inRefresh && _alarmSnapshotIds.TryGetValue(handle, out var epoch) ? epoch : null));
}
private static NativeAlarmTransition SnapshotBoundary(AlarmTransitionKind kind, string? source, Guid epoch) => new(
"", source ?? string.Empty, "", kind,
new Commons.Types.Alarms.AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "",
SnapshotSourceReference: source ?? string.Empty, SnapshotId: epoch);
/// <summary>
/// Nodes per OPC UA Read/Write service call. Stays under the
/// <c>MaxNodesPerRead</c> / <c>MaxNodesPerWrite</c> operation limits typical servers
/// advertise, so a 37,500-tag re-seed never builds one oversized request.
/// </summary>
private const int MaxNodesPerServiceCall = 1000;
/// <inheritdoc />
public async Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
string nodeId, CancellationToken cancellationToken = default)
{
// Batch-of-one delegation; a resolution failure still surfaces as the thrown
// exception the single-node callers expect.
var outcomes = await ReadValuesAsync([nodeId], cancellationToken);
var outcome = outcomes[0];
if (outcome.Error != null)
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, outcome.Error);
return (outcome.Value, outcome.SourceTimestamp, outcome.StatusCode);
}
/// <inheritdoc />
public async Task<IReadOnlyList<OpcUaReadOutcome>> ReadValuesAsync(
IReadOnlyList<string> nodeIds, CancellationToken cancellationToken = default)
{
var session = _session;
if (session == null) throw new InvalidOperationException("Not connected.");
var outcomes = new OpcUaReadOutcome[nodeIds.Count];
// Resolve first — an unresolvable node id is a per-node fault, not a batch abort.
var resolvable = new List<(int Index, string NodeId, ReadValueId Read)>(nodeIds.Count);
for (var i = 0; i < nodeIds.Count; i++)
{
try
{
resolvable.Add((i, nodeIds[i], new ReadValueId
{
NodeId = OpcUaNodeReference.Resolve(nodeIds[i], session.NamespaceUris),
AttributeId = Attributes.Value
}));
}
catch (Exception ex)
{
outcomes[i] = new OpcUaReadOutcome(nodeIds[i], null, DateTime.UtcNow, StatusCodes.BadNodeIdUnknown, ex.Message);
}
}
for (var offset = 0; offset < resolvable.Count; offset += MaxNodesPerServiceCall)
{
var chunk = resolvable.GetRange(offset, Math.Min(MaxNodesPerServiceCall, resolvable.Count - offset));
var collection = new ReadValueIdCollection(chunk.Select(c => c.Read));
var response = await session.ReadAsync(
null, 0, MapTimestampsToReturn(_options.TimestampsToReturn), collection, cancellationToken);
for (var i = 0; i < chunk.Count; i++)
{
var (index, nodeId, _) = chunk[i];
if (response.Results is { Count: > 0 } results && i < results.Count)
{
var result = results[i];
outcomes[index] = new OpcUaReadOutcome(
nodeId, result.Value, result.SourceTimestamp, result.StatusCode.Code, null);
}
else
{
// Non-conformant server: fewer results than requested nodes.
outcomes[index] = new OpcUaReadOutcome(
nodeId, null, DateTime.UtcNow, StatusCodes.BadUnexpectedError,
"OPC UA read returned no result for this node.");
}
}
}
return outcomes;
}
/// <inheritdoc />
public async Task<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
{
// Batch-of-one delegation, mirroring ReadValueAsync.
var outcomes = await WriteValuesAsync([(nodeId, value)], cancellationToken);
var outcome = outcomes[0];
if (outcome.Error != null)
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, outcome.Error);
return outcome.StatusCode;
}
/// <inheritdoc />
public async Task<IReadOnlyList<OpcUaWriteOutcome>> WriteValuesAsync(
IReadOnlyList<(string NodeId, object? Value)> values, CancellationToken cancellationToken = default)
{
var session = _session;
if (session == null) throw new InvalidOperationException("Not connected.");
var outcomes = new OpcUaWriteOutcome[values.Count];
var resolvable = new List<(int Index, string NodeId, WriteValue Write)>(values.Count);
for (var i = 0; i < values.Count; i++)
{
try
{
resolvable.Add((i, values[i].NodeId, new WriteValue
{
NodeId = OpcUaNodeReference.Resolve(values[i].NodeId, session.NamespaceUris),
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(values[i].Value))
}));
}
catch (Exception ex)
{
outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadNodeIdUnknown, ex.Message);
}
}
for (var offset = 0; offset < resolvable.Count; offset += MaxNodesPerServiceCall)
{
var chunk = resolvable.GetRange(offset, Math.Min(MaxNodesPerServiceCall, resolvable.Count - offset));
var collection = new WriteValueCollection(chunk.Select(c => c.Write));
var response = await session.WriteAsync(null, collection, cancellationToken);
for (var i = 0; i < chunk.Count; i++)
{
var (index, nodeId, _) = chunk[i];
if (response.Results is { Count: > 0 } results && i < results.Count)
{
outcomes[index] = new OpcUaWriteOutcome(nodeId, results[i].Code, null);
}
else
{
outcomes[index] = new OpcUaWriteOutcome(
nodeId, StatusCodes.BadUnexpectedError,
"OPC UA write returned no result for this node.");
}
}
}
return outcomes;
}
/// <summary>
/// Called by the OPC UA SDK when a keep-alive response arrives (or fails).
/// When CurrentState is bad, the server is unreachable. The once-only guard is an
/// atomic compare-and-set, so a burst of failed keep-alives raises
/// <see cref="ConnectionLost"/> exactly once.
/// </summary>
private void OnSessionKeepAlive(ISession session, KeepAliveEventArgs e)
{
if (ServiceResult.IsBad(e.Status))
{
if (Interlocked.Exchange(ref _connectionLostFired, 1) != 0) return;
ConnectionLost?.Invoke();
}
}
/// <summary>
/// Asynchronously disposes the OPC UA client, disconnecting from the server.
/// </summary>
/// <returns>A task representing the asynchronous disposal.</returns>
public async ValueTask DisposeAsync()
{
await DisconnectAsync();
}
private static UserIdentity? BuildUserIdentity(OpcUaUserIdentityOptions? options)
{
if (options is null) return null;
return options.TokenType.ToUpperInvariant() switch
{
"USERNAMEPASSWORD" => new UserIdentity(
options.Username,
System.Text.Encoding.UTF8.GetBytes(options.Password ?? "")),
"X509CERTIFICATE" => new UserIdentity(
X509CertificateLoader.LoadPkcs12FromFile(
options.CertificatePath, options.CertificatePassword)),
_ => null
};
}
private static MonitoringFilter? BuildDataChangeFilter(OpcUaDeadbandOptions? deadband)
{
if (deadband is null) return null;
var deadbandType = deadband.Type.ToUpperInvariant() switch
{
"PERCENT" => DeadbandType.Percent,
_ => DeadbandType.Absolute
};
return new DataChangeFilter
{
Trigger = DataChangeTrigger.StatusValue,
DeadbandType = (uint)deadbandType,
DeadbandValue = deadband.Value
};
}
private static TimestampsToReturn MapTimestampsToReturn(string mode) =>
mode.ToUpperInvariant() switch
{
"SERVER" => TimestampsToReturn.Server,
"BOTH" => TimestampsToReturn.Both,
_ => TimestampsToReturn.Source
};
private static string ResolveStorePath(string configured, string fallbackLeaf) =>
string.IsNullOrWhiteSpace(configured)
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
: configured;
// requestedMaxReferencesPerNode: cap the server's per-call references so a
// huge flat folder cannot return an unbounded set. 500 leaves headroom for
// the downstream frame-size budget (DataConnectionActor.CapBrowseChildren)
// even with long string NodeIds; a non-empty continuation point surfaces as
// a ContinuationToken so the caller can page via BrowseNext.
private const uint BrowseMaxReferencesPerNode = 500u;
// NodeClassMask intentionally excludes ReferenceType, View, Variable-
// Type, ObjectType, DataType. UI only needs Objects (navigable),
// Variables (selectable), Methods (display-only).
private const uint BrowseNodeClassMask =
(uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method);
/// <inheritdoc />
public async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default)
{
// Mirror the SubscribeAsync/ReadAsync wrap idiom: snapshot the session
// reference once, fail fast with a typed exception if the link is
// down, then call the SDK's async API directly (no Task.Run wrap —
// the OPC Foundation SDK already provides true async I/O).
var session = _session;
if (session is null || !session.Connected)
{
throw new Commons.Interfaces.Protocol.ConnectionNotConnectedException(
"OPC UA session is not connected.");
}
// ObjectsFolder = ns=0;i=85 — the OPC UA standard server root. Empty
// / null input means "browse the root"; anything else is parsed as
// an absolute NodeId expression.
var nodeToBrowse = string.IsNullOrEmpty(parentNodeId)
? ObjectIds.ObjectsFolder
: OpcUaNodeReference.Resolve(parentNodeId, session.NamespaceUris);
// No token → fresh browse of the node. A non-empty token → continue a
// prior browse via BrowseNext, falling back to a fresh browse if the
// server has invalidated the continuation point.
if (string.IsNullOrEmpty(continuationToken))
{
return await FreshBrowseAsync(session, nodeToBrowse, cancellationToken).ConfigureAwait(false);
}
try
{
// SDK overload: BrowseNextAsync(session, requestHeader, ByteStringCollection
// continuationPoints, bool releaseContinuationPoint, ct) → returns
// (ResponseHeader, ByteStringCollection revisedContinuationPoints,
// IList<ReferenceDescriptionCollection> results, IList<ServiceResult> errors).
// releaseContinuationPoint:false keeps the point alive so the next page
// can be fetched; we pass back exactly the one point we were handed.
var (_, revisedPoints, results, _) = await session.BrowseNextAsync(
null,
new ByteStringCollection { Convert.FromBase64String(continuationToken) },
false,
cancellationToken).ConfigureAwait(false);
var nextPoint = revisedPoints is { Count: > 0 } ? revisedPoints[0] : null;
var refs = results is { Count: > 0 } ? results[0] : null;
return await BuildResultAsync(session, refs, nextPoint, cancellationToken).ConfigureAwait(false);
}
catch (ServiceResultException ex) when (
ex.StatusCode == StatusCodes.BadContinuationPointInvalid ||
ex.StatusCode == StatusCodes.BadInvalidArgument)
{
// The continuation point expired or was rejected (e.g. a fresh
// session, or the server timed it out). Recover by re-browsing the
// parent from the start and returning its first page.
_logger.LogDebug(ex,
"OPC UA BrowseNext rejected the continuation point (status {Status:X8}); " +
"falling back to a fresh browse of {Node}.", ex.StatusCode, nodeToBrowse);
return await FreshBrowseAsync(session, nodeToBrowse, cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public Task<Commons.Interfaces.Protocol.AddressSpaceSearchResult> SearchAddressSpaceAsync(
string query, int maxDepth, int maxResults, CancellationToken cancellationToken = default)
{
// Fail fast with the typed exception when the link is down — mirrors the
// BrowseChildrenAsync guard. (BrowseChildrenAsync would also throw on the
// first page, but guarding here keeps the empty-query / zero-cap short-circuit
// from masking a disconnected session.)
var session = _session;
if (session is null || !session.Connected)
{
throw new Commons.Interfaces.Protocol.ConnectionNotConnectedException(
"OPC UA session is not connected.");
}
// Bounded BFS over this client's OWN BrowseChildrenAsync — the shared
// helper pages each node's continuation (BrowseNext) to the end, so a
// big folder's later pages are searched too, not just the first page.
return AddressSpaceSearch.SearchAsync(
BrowseChildrenAsync, query, maxDepth, maxResults, cancellationToken);
}
/// <summary>
/// Issues a fresh <see cref="SessionClientExtensions.BrowseAsync"/> of
/// <paramref name="nodeToBrowse"/> and returns its first page, surfacing any
/// continuation point as a token for paging.
/// </summary>
private async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> FreshBrowseAsync(
ISession session, NodeId nodeToBrowse, CancellationToken cancellationToken)
{
var (_, continuationPoint, references) = await session.BrowseAsync(
null,
null,
nodeToBrowse,
BrowseMaxReferencesPerNode,
BrowseDirection.Forward,
ReferenceTypeIds.HierarchicalReferences,
true,
BrowseNodeClassMask,
cancellationToken).ConfigureAwait(false);
return await BuildResultAsync(session, references, continuationPoint, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Shared "build children + B1 type enrichment + continuation token" logic
/// used by both the initial browse and the BrowseNext paths so the
/// enrichment is never duplicated. A non-empty
/// <paramref name="continuationPoint"/> is surfaced as a Base64
/// <c>ContinuationToken</c> with <c>Truncated=true</c>; otherwise the result
/// is exhausted (<c>ContinuationToken=null, Truncated=false</c>).
/// </summary>
private async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BuildResultAsync(
ISession session,
ReferenceDescriptionCollection? references,
byte[]? continuationPoint,
CancellationToken cancellationToken)
{
var refs = references ?? new ReferenceDescriptionCollection();
var children = new List<Commons.Interfaces.Protocol.BrowseNode>(refs.Count);
foreach (var r in refs)
{
children.Add(new Commons.Interfaces.Protocol.BrowseNode(
// Durable nsu= form, not r.NodeId.ToString(): the raw ExpandedNodeId
// string can carry a namespace URI or server index that Resolve cannot
// read back, and a bare ns= index rots the moment the server's
// namespace table changes. What the picker shows is what gets stored.
NodeId: OpcUaNodeReference.ToDurable(r.NodeId, session.NamespaceUris),
DisplayName: r.DisplayName?.Text ?? r.BrowseName?.Name ?? "(unnamed)",
NodeClass: MapNodeClass(r.NodeClass),
HasChildren: r.NodeClass == NodeClass.Object));
}
// B1 type-info: enrich Variable rows with DataType / ValueRank /
// Writable so the node picker can show types. Best-effort: ONE batched
// ReadAsync over (DataType, ValueRank, UserAccessLevel) for every
// Variable child; on ANY failure we leave the three fields null and
// return the children exactly as built above. Non-Variable nodes are
// never read and keep null type info. Runs identically on the browse
// and browse-next pages.
children = await EnrichVariableTypeInfoAsync(session, refs, children, cancellationToken)
.ConfigureAwait(false);
// A non-empty continuation point means the server has more refs than
// were returned on this page. Surface it as an opaque Base64 token the
// caller passes back to fetch the next page via BrowseNext.
var hasMore = continuationPoint != null && continuationPoint.Length > 0;
var token = hasMore ? Convert.ToBase64String(continuationPoint!) : null;
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, hasMore, token);
}
private static Commons.Interfaces.Protocol.BrowseNodeClass MapNodeClass(NodeClass nc) => nc switch
{
NodeClass.Object => Commons.Interfaces.Protocol.BrowseNodeClass.Object,
NodeClass.Variable => Commons.Interfaces.Protocol.BrowseNodeClass.Variable,
NodeClass.Method => Commons.Interfaces.Protocol.BrowseNodeClass.Method,
_ => Commons.Interfaces.Protocol.BrowseNodeClass.Other
};
// Type-info: best-effort enrichment of Variable rows with DataType,
// ValueRank, and Writable. Reads all three attributes for every Variable
// child in ONE ReadAsync round-trip; on any failure (or zero variables)
// returns the input children unchanged. Caller has already built the
// BrowseNode list, so a swallowed failure simply means no type columns.
private async Task<List<Commons.Interfaces.Protocol.BrowseNode>> EnrichVariableTypeInfoAsync(
ISession session,
ReferenceDescriptionCollection refs,
List<Commons.Interfaces.Protocol.BrowseNode> children,
CancellationToken cancellationToken)
{
try
{
// Build a flat read of (DataType, ValueRank, UserAccessLevel) per
// Variable child, remembering which children-list slot each triple
// maps back to so the read values can be re-stitched in order.
var readIds = new ReadValueIdCollection();
var variableSlots = new List<int>();
for (var i = 0; i < refs.Count; i++)
{
if (refs[i].NodeClass != NodeClass.Variable)
{
continue;
}
var nodeId = ExpandedNodeId.ToNodeId(refs[i].NodeId, session.NamespaceUris);
if (nodeId is null)
{
continue;
}
variableSlots.Add(i);
readIds.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.DataType });
readIds.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.ValueRank });
readIds.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.UserAccessLevel });
}
if (variableSlots.Count == 0)
{
return children;
}
var readResponse = await session.ReadAsync(
null,
0,
TimestampsToReturn.Neither,
readIds,
cancellationToken).ConfigureAwait(false);
var results = readResponse.Results;
if (results is null || results.Count != readIds.Count)
{
// Defensive: a non-conformant server. Skip enrichment entirely
// rather than risk mis-aligning values to the wrong nodes.
return children;
}
for (var v = 0; v < variableSlots.Count; v++)
{
var baseIdx = v * 3;
var dataTypeResult = results[baseIdx];
var valueRankResult = results[baseIdx + 1];
var accessLevelResult = results[baseIdx + 2];
string? dataType = null;
if (StatusCode.IsGood(dataTypeResult.StatusCode) && dataTypeResult.Value is NodeId dtNodeId)
{
dataType = OpcUaBuiltInTypeNames.Resolve(dtNodeId);
}
int? valueRank = null;
if (StatusCode.IsGood(valueRankResult.StatusCode) && valueRankResult.Value is int vr)
{
valueRank = vr;
}
bool? writable = null;
if (StatusCode.IsGood(accessLevelResult.StatusCode) && accessLevelResult.Value is byte accessLevel)
{
writable = ((AccessLevelType)accessLevel).HasFlag(AccessLevelType.CurrentWrite);
}
var slot = variableSlots[v];
children[slot] = children[slot] with
{
DataType = dataType,
ValueRank = valueRank,
Writable = writable
};
}
return children;
}
catch (OperationCanceledException)
{
// Honour cancellation — the caller's BrowseAsync already completed,
// but a cancelled type-read should propagate, not be swallowed as a
// best-effort miss.
throw;
}
catch (Exception ex)
{
// Best-effort: any other failure (server quirk, transient read
// error, type surprise) must NOT fail the browse. Return the
// children as originally built, with null type info.
_logger.LogDebug(ex, "Best-effort OPC UA variable type-info read failed; returning browse results without type columns.");
return children;
}
}
}
/// <summary>
/// Type-info: maps well-known OPC UA built-in <c>DataType</c> NodeIds
/// (namespace 0 numeric ids in <see cref="DataTypeIds"/>) to friendly,
/// CLR-flavoured names for display in the node picker. Vendor / structured
/// DataTypes (anything not in the built-in table) fall back to the NodeId
/// string — the only thing meaningful the UI can render for an opaque type.
/// A null input yields the empty string so a missing DataType attribute on a
/// best-effort browse read never throws.
/// </summary>
internal static class OpcUaBuiltInTypeNames
{
private static readonly IReadOnlyDictionary<NodeId, string> Names = new Dictionary<NodeId, string>
{
[DataTypeIds.Boolean] = "Boolean",
[DataTypeIds.SByte] = "SByte",
[DataTypeIds.Byte] = "Byte",
[DataTypeIds.Int16] = "Int16",
[DataTypeIds.UInt16] = "UInt16",
[DataTypeIds.Int32] = "Int32",
[DataTypeIds.UInt32] = "UInt32",
[DataTypeIds.Int64] = "Int64",
[DataTypeIds.UInt64] = "UInt64",
[DataTypeIds.Float] = "Float",
[DataTypeIds.Double] = "Double",
[DataTypeIds.String] = "String",
[DataTypeIds.DateTime] = "DateTime",
[DataTypeIds.Guid] = "Guid",
[DataTypeIds.ByteString] = "ByteString",
[DataTypeIds.XmlElement] = "XmlElement",
[DataTypeIds.NodeId] = "NodeId",
[DataTypeIds.ExpandedNodeId] = "ExpandedNodeId",
[DataTypeIds.StatusCode] = "StatusCode",
[DataTypeIds.QualifiedName] = "QualifiedName",
[DataTypeIds.LocalizedText] = "LocalizedText",
[DataTypeIds.DataValue] = "DataValue",
[DataTypeIds.Number] = "Number",
[DataTypeIds.Integer] = "Integer",
[DataTypeIds.UInteger] = "UInteger",
[DataTypeIds.Enumeration] = "Enumeration",
[DataTypeIds.BaseDataType] = "BaseDataType"
};
/// <summary>
/// Resolves a DataType NodeId to a friendly built-in name, or the NodeId
/// string for unknown / vendor types. Null returns <see cref="string.Empty"/>.
/// </summary>
/// <param name="dataTypeNodeId">The DataType attribute value read from the server, or null.</param>
/// <returns>Friendly name for built-ins; NodeId string for unknowns; empty string for null.</returns>
public static string Resolve(NodeId? dataTypeNodeId)
{
if (dataTypeNodeId is null)
{
return string.Empty;
}
return Names.TryGetValue(dataTypeNodeId, out var name)
? name
: dataTypeNodeId.ToString();
}
}
/// <summary>
/// Factory that creates real OPC UA client instances using the OPC Foundation SDK.
/// </summary>
public class RealOpcUaClientFactory : IOpcUaClientFactory
{
private readonly OpcUaGlobalOptions _globalOptions;
// A real logger must be threaded through to every
// RealOpcUaClient this factory builds, otherwise the auto-accept-certificate
// warning emitted in RealOpcUaClient.ConnectAsync sinks into NullLogger and is never
// seen in production. The factory is constructed by DataConnectionFactory, which has
// an ILoggerFactory available.
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes a new instance of the RealOpcUaClientFactory class with default options.
/// </summary>
public RealOpcUaClientFactory() : this(new OpcUaGlobalOptions()) { }
/// <summary>
/// Initializes a new instance of the RealOpcUaClientFactory class with global options.
/// </summary>
/// <param name="globalOptions">Global OPC UA options.</param>
public RealOpcUaClientFactory(OpcUaGlobalOptions globalOptions)
: this(globalOptions, NullLoggerFactory.Instance) { }
/// <summary>
/// Initializes a new instance of the RealOpcUaClientFactory class with options and logger factory.
/// </summary>
/// <param name="globalOptions">Global OPC UA options.</param>
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
public RealOpcUaClientFactory(OpcUaGlobalOptions globalOptions, ILoggerFactory loggerFactory)
{
_globalOptions = globalOptions;
_loggerFactory = loggerFactory;
}
/// <inheritdoc />
public IOpcUaClient Create() =>
new RealOpcUaClient(_globalOptions, _loggerFactory.CreateLogger<RealOpcUaClient>());
}