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; /// /// Real OPC UA client implementation using the OPC Foundation .NET Standard Library. /// Wraps Session, Subscription, and MonitoredItem for tag subscriptions. /// public class RealOpcUaClient : IOpcUaClient { private ISession? _session; /// /// One data shard = one OPC UA Subscription holding at most /// 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 /// ). /// private sealed class DataShard(Subscription subscription) { /// The OPC UA subscription backing this shard. public Subscription Subscription { get; } = subscription; /// The number of monitored items currently placed on this shard. 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 _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 _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 _monitoredItems = new(); private readonly ConcurrentDictionary> _callbacks = new(); // Native alarm (A&C) event subscriptions, keyed by handle. private readonly ConcurrentDictionary _alarmItems = new(); // Per-handle "currently inside a ConditionRefresh replay" flag → Snapshot kind. private readonly ConcurrentDictionary _alarmInRefresh = new(); private readonly ConcurrentDictionary _alarmSnapshotIds = new(); // Per-handle last (active, acked) by source reference, to derive transition kind. private readonly ConcurrentDictionary> _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 _logger; /// /// Initializes a new instance of the RealOpcUaClient class. /// /// Global OPC UA options, or null to use defaults. /// Logger instance, or null to use a null logger. public RealOpcUaClient(OpcUaGlobalOptions? globalOptions = null, ILogger? logger = null) { _globalOptions = globalOptions ?? new OpcUaGlobalOptions(); _logger = logger ?? NullLogger.Instance; } /// public bool IsConnected => _session?.Connected ?? false; /// /// Asynchronous endpoint discovery bounded by 's /// TransportQuotas.OperationTimeout (the connection's OperationTimeoutMs) and by /// (review 2026-10-21 02/P2). It replaces the synchronous /// DiscoveryClient.Create(uri).GetEndpoints(null), which ran on whatever thread called /// the connect — the actor thread, when ValidateAsync completed synchronously — under /// the SDK's default 120 s operation timeout, and could not be cancelled by a verify probe's /// budget. /// private static async Task 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); } /// Raised when the OPC UA connection is lost. public event Action? ConnectionLost; /// Selects exactly the requested protection mode; protected discovery failures fail closed. /// The discovery URL to query. /// The security mode that must be matched exactly. /// Delegate that returns the server's advertised endpoints, honouring the token. /// /// Bounds the discovery. A cancellation is rethrown — never mistaken for "discovery /// unavailable", which would otherwise fall back to an unsecured endpoint and carry on. /// /// The endpoint matching the requested security mode. internal static async Task DiscoverEndpointAsync(string url, MessageSecurityMode requested, Func> 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())}."); } /// 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); } /// Item budget per subscription, guarding against a non-positive configured value. private int ItemsPerShard => _options.MaxMonitoredItemsPerSubscription > 0 ? _options.MaxMonitoredItemsPerSubscription : 5000; /// /// Builds a Subscription from the current connection options. Callers must hold /// . /// 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 }; /// /// Adds and creates a new data shard. Callers must hold . /// private async Task 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; } /// /// Returns the dedicated Alarms & Conditions event shard, creating it on first /// use. Callers must hold . /// private async Task 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; } /// /// Rewrites a discovered endpoint's host/port to match the reachable URL the operator /// configured, when the server advertised a different (unroutable) one. /// /// /// An OPC UA server advertises its base address from its OWN configuration, not the route the /// client took to reach it — so GetEndpoints routinely returns a wildcard bind /// (opc.tcp://0.0.0.0:4840/…) or an internal container/NAT hostname the client cannot /// dial. The OPC Foundation session opens its channel to /// 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. /OtOpcUa). Mirrors CoreClientUtils.SelectEndpoint. /// 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). /// /// The discovered endpoint to rewrite in place; may be null. /// The reachable URL the operator configured. 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}"; } /// /// 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 /// AutoAcceptUntrustedCertificates = false and a validation hook that captures an /// untrusted server certificate then REJECTS it (e.Accept = false). It never trusts /// the certificate — trusting is a separate, later operator action. The session is always /// disposed in a finally. /// /// The endpoint configuration to probe. /// Deployment-wide OPC UA application identity / cert-store paths. /// Logger for diagnostics. /// Wall-clock budget for the whole probe (discovery + session create). /// External cancellation token, linked with the timeout. /// A structured classifying the outcome. public static async Task 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); } /// /// Pure mapping of a probe outcome — an optional exception plus an optionally /// captured untrusted server certificate — to a . /// Factored out so the classification is unit-testable WITHOUT a live OPC UA server. /// Precedence: a captured certificate ALWAYS yields /// ; otherwise the exception is /// classified; null exception + null cert means the session was created (success). /// /// The exception thrown during the probe, or null on success. /// The untrusted server certificate captured by the validation hook, or null. /// The classified verification result. 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); } /// /// Walks the exception's InnerException chain looking for a /// — the signature of a DNS-resolution /// or connection-refused failure that means the endpoint host is unreachable. /// 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; } /// 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(); } /// public async Task CreateSubscriptionAsync( string nodeId, Action 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."); } /// public async Task> CreateSubscriptionsAsync( IReadOnlyList nodeIds, Action onValueChanged, CancellationToken cancellationToken = default) { var (outcomes, _) = await CreateSubscriptionsCoreAsync(nodeIds, onValueChanged, cancellationToken); return outcomes; } /// /// 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. /// private async Task<(OpcUaSubscribeOutcome[] Outcomes, Exception?[] Errors)> CreateSubscriptionsCoreAsync( IReadOnlyList nodeIds, Action 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(); 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); } /// /// Places every resolved node on a data shard and issues ONE ApplyChanges per touched shard. /// Callers must hold . and /// are filled as the work proceeds so a throw can be unwound by /// . /// private async Task PlaceAndApplyAsync( List<(int Index, string NodeId, NodeId Resolved)> resolved, Action onValueChanged, List<(int Index, string NodeId, string Handle, MonitoredItem Item)> created, List 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); } /// /// Rolls back the monitored items a FAILED batch had already placed, so a throw leaves no /// residue behind: no _callbacks entry (a leak that also keeps a dead callback /// reachable from the SDK's publish thread), no _itemShard entry, no phantom shard /// occupancy, and no item left on the shard's local item list. Callers must hold /// . /// /// /// 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. /// /// 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); } /// /// Drops a monitored item that failed to create from the shard it was placed on. /// Callers must hold . No ApplyChanges is issued: the server /// never created the item, so removing it locally keeps the budget honest. /// 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--; } /// public Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default) => RemoveSubscriptionsAsync([subscriptionHandle], cancellationToken); /// public async Task RemoveSubscriptionsAsync( IReadOnlyList subscriptionHandles, CancellationToken cancellationToken = default) { if (subscriptionHandles.Count == 0) return; await _applyGate.RunAsync(async () => { var touched = new List(); 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"]; /// public async Task CreateAlarmSubscriptionAsync( string? sourceNodeId, string? conditionFilter, Action onTransition, CancellationToken cancellationToken = default) { if (_session == null) throw new InvalidOperationException("Not connected."); var handle = Guid.NewGuid().ToString(); _alarmInRefresh[handle] = false; _alarmLastState[handle] = new Dictionary(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; } /// 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 _); } /// /// Maps the standard OPC UA Alarms & Conditions type names (case-insensitive) /// to their well-known 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. /// /// Single source of truth for both directions: /// is derived from this map, so the friendly-name and NodeId sides cannot drift. /// /// internal static readonly IReadOnlyDictionary KnownConditionTypeIds = new Dictionary(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, }; /// /// Inverse of (NodeId → friendly name), derived /// from it so the two cannot drift. Used by /// 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. /// private static readonly IReadOnlyDictionary ConditionTypeNamesById = KnownConditionTypeIds.ToDictionary(kv => kv.Value, kv => kv.Key); /// /// Resolves an event-type to the friendly condition-type name the /// conditionFilter gate (and the server-side WhereClause) use. /// /// /// Standard A&C types are returned as their friendly name (e.g. i=9341 → /// "ExclusiveLevelAlarmType") so the client-side gate — which compares against /// the friendly names in — 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 null event type yields the empty string. /// /// /// The event-type NodeId from the A&C notification, or null. /// The friendly type name when known; otherwise the NodeId string (or "" when null). internal static string ResolveAlarmTypeName(NodeId? eventType) { if (eventType is null) return ""; return ConditionTypeNamesById.TryGetValue(eventType, out var friendly) ? friendly : eventType.ToString(); } /// /// Builds the event filter selecting the base event fields plus the /// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror, /// and — when is non-empty and every requested /// type maps to a standard A&C type — a server-side /// WhereClause (OfType, OR'd) as a bandwidth optimisation. /// /// /// Conservative by design: if any requested type name cannot be mapped to /// a standard 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. /// /// /// The parsed condition-type filter (allow-all when empty). /// The configured . 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; } /// /// Attaches an OfType(-OR'd) WhereClause to when every /// requested condition type maps to a standard A&C type NodeId; otherwise leaves /// the WhereClause empty (see rationale). /// private static void ApplyServerSideTypeWhereClause(EventFilter filter, AlarmConditionFilter conditionFilter) { if (conditionFilter.IsEmpty) return; var typeIds = new List(); 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 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); /// /// Nodes per OPC UA Read/Write service call. Stays under the /// MaxNodesPerRead / MaxNodesPerWrite operation limits typical servers /// advertise, so a 37,500-tag re-seed never builds one oversized request. /// private const int MaxNodesPerServiceCall = 1000; /// 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); } /// public async Task> ReadValuesAsync( IReadOnlyList 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; } /// public async Task 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; } /// public async Task> 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; } /// /// 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 /// exactly once. /// private void OnSessionKeepAlive(ISession session, KeepAliveEventArgs e) { if (ServiceResult.IsBad(e.Status)) { if (Interlocked.Exchange(ref _connectionLostFired, 1) != 0) return; ConnectionLost?.Invoke(); } } /// /// Asynchronously disposes the OPC UA client, disconnecting from the server. /// /// A task representing the asynchronous disposal. 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); /// public async Task 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 results, IList 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); } } /// public Task 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); } /// /// Issues a fresh of /// and returns its first page, surfacing any /// continuation point as a token for paging. /// private async Task 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); } /// /// 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 /// is surfaced as a Base64 /// ContinuationToken with Truncated=true; otherwise the result /// is exhausted (ContinuationToken=null, Truncated=false). /// private async Task BuildResultAsync( ISession session, ReferenceDescriptionCollection? references, byte[]? continuationPoint, CancellationToken cancellationToken) { var refs = references ?? new ReferenceDescriptionCollection(); var children = new List(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> EnrichVariableTypeInfoAsync( ISession session, ReferenceDescriptionCollection refs, List 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(); 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; } } } /// /// Type-info: maps well-known OPC UA built-in DataType NodeIds /// (namespace 0 numeric ids in ) 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. /// internal static class OpcUaBuiltInTypeNames { private static readonly IReadOnlyDictionary Names = new Dictionary { [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" }; /// /// Resolves a DataType NodeId to a friendly built-in name, or the NodeId /// string for unknown / vendor types. Null returns . /// /// The DataType attribute value read from the server, or null. /// Friendly name for built-ins; NodeId string for unknowns; empty string for null. public static string Resolve(NodeId? dataTypeNodeId) { if (dataTypeNodeId is null) { return string.Empty; } return Names.TryGetValue(dataTypeNodeId, out var name) ? name : dataTypeNodeId.ToString(); } } /// /// Factory that creates real OPC UA client instances using the OPC Foundation SDK. /// 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; /// /// Initializes a new instance of the RealOpcUaClientFactory class with default options. /// public RealOpcUaClientFactory() : this(new OpcUaGlobalOptions()) { } /// /// Initializes a new instance of the RealOpcUaClientFactory class with global options. /// /// Global OPC UA options. public RealOpcUaClientFactory(OpcUaGlobalOptions globalOptions) : this(globalOptions, NullLoggerFactory.Instance) { } /// /// Initializes a new instance of the RealOpcUaClientFactory class with options and logger factory. /// /// Global OPC UA options. /// Logger factory for creating loggers. public RealOpcUaClientFactory(OpcUaGlobalOptions globalOptions, ILoggerFactory loggerFactory) { _globalOptions = globalOptions; _loggerFactory = loggerFactory; } /// public IOpcUaClient Create() => new RealOpcUaClient(_globalOptions, _loggerFactory.CreateLogger()); }