Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
T
Joseph Doherty 86d129de6c fix(dcl): harden endpoint host-rewrite — IPv6 brackets + portless URLs
Code review of a1abbff7 found two edge cases: a discovery/advertised URL with no
explicit port emitted a malformed ':-1' (opc.tcp has no Uri default port), and an
IPv6 literal host could lose/double its brackets. Omit the port when neither URL
carries one; bracket an IPv6 host only when missing. +4 tests (12 total).
2026-07-23 16:58:44 -04:00

1381 lines
67 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;
private Subscription? _subscription;
// 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();
// 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>Raised when the OPC UA connection is lost.</summary>
public event Action? ConnectionLost;
/// <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
EndpointDescription? endpoint;
try
{
#pragma warning disable CS0618
using var discoveryClient = DiscoveryClient.Create(new Uri(endpointUrl));
#pragma warning restore CS0618
#pragma warning disable CS0618
var endpoints = discoveryClient.GetEndpoints(null);
#pragma warning restore CS0618
endpoint = endpoints
.Where(e => e.SecurityMode == preferredSecurityMode)
.FirstOrDefault() ?? endpoints.FirstOrDefault();
}
catch
{
// Fallback: construct endpoint description manually
endpoint = new EndpointDescription(endpointUrl);
}
// 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 a default subscription for all monitored items
_subscription = new Subscription(_session.DefaultSubscription)
{
DisplayName = opts.SubscriptionDisplayName,
Priority = opts.SubscriptionPriority,
PublishingEnabled = true,
PublishingInterval = opts.PublishingIntervalMs,
KeepAliveCount = (uint)opts.KeepAliveCount,
LifetimeCount = (uint)opts.LifetimeCount,
MaxNotificationsPerPublish = (uint)opts.MaxNotificationsPerPublish
};
_session.AddSubscription(_subscription);
await _subscription.CreateAsync(cancellationToken);
}
/// <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>
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).
EndpointDescription? endpoint;
try
{
#pragma warning disable CS0618
using var discoveryClient = DiscoveryClient.Create(new Uri(endpointUrl));
var endpoints = discoveryClient.GetEndpoints(null);
#pragma warning restore CS0618
endpoint = endpoints
.Where(ep => ep.SecurityMode == preferredSecurityMode)
.FirstOrDefault() ?? endpoints.FirstOrDefault();
}
catch
{
endpoint = new EndpointDescription(endpointUrl);
}
// 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)
{
if (_subscription != null)
{
await _subscription.DeleteAsync(true);
_subscription = null;
}
if (_session != null)
{
_session.KeepAlive -= OnSessionKeepAlive;
await _session.CloseAsync(cancellationToken);
_session = null;
}
_monitoredItems.Clear();
_callbacks.Clear();
}
/// <inheritdoc />
public async Task<string> CreateSubscriptionAsync(
string nodeId, Action<string, object?, DateTime, uint> onValueChanged,
CancellationToken cancellationToken = default)
{
if (_subscription == null || _session == null)
throw new InvalidOperationException("Not connected.");
var handle = Guid.NewGuid().ToString();
var monitoredItem = new MonitoredItem(_subscription.DefaultItem)
{
DisplayName = nodeId,
StartNodeId = OpcUaNodeReference.Resolve(nodeId, _session.NamespaceUris),
AttributeId = Attributes.Value,
SamplingInterval = _options.SamplingIntervalMs,
QueueSize = (uint)_options.QueueSize,
DiscardOldest = _options.DiscardOldest,
Filter = BuildDataChangeFilter(_options.Deadband)
};
_callbacks[handle] = onValueChanged;
monitoredItem.Notification += (item, 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);
}
}
};
_subscription.AddItem(monitoredItem);
await _subscription.ApplyChangesAsync(cancellationToken);
_monitoredItems[handle] = monitoredItem;
return handle;
}
/// <inheritdoc />
public async Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
{
if (_subscription != null && _monitoredItems.TryGetValue(subscriptionHandle, out var item))
{
_subscription.RemoveItem(item);
await _subscription.ApplyChangesAsync(cancellationToken);
_monitoredItems.TryRemove(subscriptionHandle, out _);
_callbacks.TryRemove(subscriptionHandle, out _);
}
}
// ── 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 (_subscription == null || _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);
var item = new MonitoredItem(_subscription.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 (see #17).
HandleAlarmEvent(handle, sourceNodeId, efl, onTransition);
};
_subscription.AddItem(item);
await _subscription.ApplyChangesAsync(cancellationToken);
_alarmItems[handle] = item;
// Replay currently-active conditions as a Snapshot…SnapshotComplete sequence.
await TriggerConditionRefreshAsync(handle, cancellationToken);
return handle;
}
/// <inheritdoc />
public async Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
{
if (_subscription != null && _alarmItems.TryRemove(subscriptionHandle, out var item))
{
_subscription.RemoveItem(item);
await _subscription.ApplyChangesAsync(cancellationToken);
}
_alarmInRefresh.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
// 1417: 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
// 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, CancellationToken cancellationToken)
{
try
{
// ConditionRefresh replays active conditions; RefreshStart/End events
// bracket the replay so HandleAlarmEvent can mark them Snapshot.
await _session!.CallAsync(
ObjectTypeIds.ConditionType, MethodIds.ConditionType_ConditionRefresh,
cancellationToken, _subscription!.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;
return;
}
if (eventType == ObjectTypeIds.RefreshEndEventType)
{
_alarmInRefresh[handle] = false;
onTransition(SnapshotComplete());
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 #17
// and 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 1417: 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);
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);
lastState?.TryAdd(sourceRef, (active, acked));
if (lastState != null) 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));
}
private static NativeAlarmTransition SnapshotComplete() => new(
"", "", "", AlarmTransitionKind.SnapshotComplete,
new Commons.Types.Alarms.AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");
/// <inheritdoc />
public async Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
string nodeId, CancellationToken cancellationToken = default)
{
if (_session == null) throw new InvalidOperationException("Not connected.");
var readValue = new ReadValueId
{
NodeId = OpcUaNodeReference.Resolve(nodeId, _session.NamespaceUris),
AttributeId = Attributes.Value
};
var response = await _session.ReadAsync(
null, 0, MapTimestampsToReturn(_options.TimestampsToReturn),
new ReadValueIdCollection { readValue }, cancellationToken);
var result = response.Results[0];
return (result.Value, result.SourceTimestamp, result.StatusCode.Code);
}
/// <inheritdoc />
public async Task<uint> WriteValueAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
{
if (_session == null) throw new InvalidOperationException("Not connected.");
var writeValue = new WriteValue
{
NodeId = OpcUaNodeReference.Resolve(nodeId, _session.NamespaceUris),
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value))
};
var response = await _session.WriteAsync(
null, new WriteValueCollection { writeValue }, cancellationToken);
return response.Results[0].Code;
}
/// <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>());
}