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; 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 _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(); // 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; /// Raised when the OPC UA connection is lost. public event Action? ConnectionLost; /// 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); } /// /// 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). /// 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). 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); } /// /// 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) { 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(); } /// public async Task CreateSubscriptionAsync( string nodeId, Action 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; } /// 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"]; /// public async Task CreateAlarmSubscriptionAsync( string? sourceNodeId, string? conditionFilter, Action 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(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; } /// 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 _); } /// /// 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 // 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, 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 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 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); 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, "", ""); /// 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); } /// public async Task 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; } /// /// 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()); }