using System.Text; using Opc.Ua; using Serilog; using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters; using ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers; using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; using BrowseResult = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.BrowseResult; namespace ZB.MOM.WW.OtOpcUa.Client.Shared; /// /// Full implementation of using adapter abstractions for testability. /// public sealed class OpcUaClientService : IOpcUaClientService { private static readonly ILogger Logger = Log.ForContext(); // Guards all access to the subscription-bookkeeping state below // (_activeDataSubscriptions and _activeAlarmSubscription). The dictionary // and tuple are mutated from the caller thread, the keep-alive failover // path, and DisconnectAsync, so every read/write must be inside this lock. private readonly object _subscriptionLock = new(); // Serialises SubscribeAlarmsAsync / UnsubscribeAlarmsAsync so concurrent callers // cannot both pass the _alarmSubscription == null check and create duplicate event // subscriptions. Capacity 1 = at most one waiter; async-friendly (no thread-block). private readonly SemaphoreSlim _alarmSubscribeSemaphore = new(1, 1); // Track active data subscriptions for replay after failover private readonly Dictionary _activeDataSubscriptions = new(); // Re-entry guard for HandleKeepAliveFailureAsync. The OPC UA stack raises // KeepAlive repeatedly while a session is down; only one failover loop may // run at a time. 0 = idle, 1 = failover in progress (Interlocked-managed). private int _failoverInProgress; private readonly IApplicationConfigurationFactory _configFactory; private readonly IEndpointDiscovery _endpointDiscovery; private readonly ISessionFactory _sessionFactory; // Track alarm subscription state for replay after failover private (NodeId? SourceNodeId, int IntervalMs)? _activeAlarmSubscription; private ISubscriptionAdapter? _alarmSubscription; private string[]? _allEndpointUrls; private int _currentEndpointIndex; private ISubscriptionAdapter? _dataSubscription; private bool _disposed; private ISessionAdapter? _session; private ConnectionSettings? _settings; private ConnectionState _state = ConnectionState.Disconnected; /// /// Creates a new OpcUaClientService with the specified adapter dependencies. /// /// Builds the application configuration and certificate settings for the client session. /// Selects the best matching endpoint for the requested URL and security mode. /// Creates the underlying OPC UA session used for runtime operations. internal OpcUaClientService( IApplicationConfigurationFactory configFactory, IEndpointDiscovery endpointDiscovery, ISessionFactory sessionFactory) { _configFactory = configFactory; _endpointDiscovery = endpointDiscovery; _sessionFactory = sessionFactory; } /// /// Creates a new OpcUaClientService with default production adapters. /// public OpcUaClientService() : this( new DefaultApplicationConfigurationFactory(), new DefaultEndpointDiscovery(), new DefaultSessionFactory()) { } /// public event EventHandler? DataChanged; /// public event EventHandler? AlarmEvent; /// public event EventHandler? ConnectionStateChanged; /// public bool IsConnected => _state == ConnectionState.Connected && _session?.Connected == true; /// public ConnectionInfo? CurrentConnectionInfo { get; private set; } /// public async Task ConnectAsync(ConnectionSettings settings, CancellationToken ct = default) { ThrowIfDisposed(); settings.Validate(); _settings = settings; _allEndpointUrls = FailoverUrlParser.Parse(settings.EndpointUrl, settings.FailoverUrls); _currentEndpointIndex = 0; TransitionState(ConnectionState.Connecting, settings.EndpointUrl); try { var session = await ConnectToEndpointAsync(settings, _allEndpointUrls[0], ct); _session = session; session.RegisterKeepAliveHandler(isGood => { if (!isGood) _ = HandleKeepAliveFailureAsync(); }); CurrentConnectionInfo = BuildConnectionInfo(session); TransitionState(ConnectionState.Connected, session.EndpointUrl); Logger.Information("Connected to {EndpointUrl}", session.EndpointUrl); return CurrentConnectionInfo; } catch { TransitionState(ConnectionState.Disconnected, settings.EndpointUrl); throw; } } /// public async Task DisconnectAsync(CancellationToken ct = default) { if (_state == ConnectionState.Disconnected) return; var endpointUrl = _session?.EndpointUrl ?? _settings?.EndpointUrl ?? string.Empty; // Snapshot the subscription adapter references under the lock so a concurrent // RunFailoverAsync that nulls _dataSubscription / _alarmSubscription // cannot invalidate the references between the null-check and the DeleteAsync call. ISubscriptionAdapter? dataSubscription; ISubscriptionAdapter? alarmSubscription; lock (_subscriptionLock) { dataSubscription = _dataSubscription; alarmSubscription = _alarmSubscription; _dataSubscription = null; _alarmSubscription = null; } try { if (dataSubscription != null) await dataSubscription.DeleteAsync(ct); if (alarmSubscription != null) await alarmSubscription.DeleteAsync(ct); if (_session != null) { await _session.CloseAsync(ct); _session.Dispose(); _session = null; } } catch (Exception ex) { Logger.Warning(ex, "Error during disconnect"); } finally { lock (_subscriptionLock) { _activeDataSubscriptions.Clear(); _activeAlarmSubscription = null; } CurrentConnectionInfo = null; TransitionState(ConnectionState.Disconnected, endpointUrl); } } /// public async Task ReadValueAsync(NodeId nodeId, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); return await _session!.ReadValueAsync(nodeId, ct); } /// public async Task WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); // Read current value for type coercion when value is a string var typedValue = value; if (value is string rawString) { var currentDataValue = await _session!.ReadValueAsync(nodeId, ct); if (!StatusCode.IsGood(currentDataValue.StatusCode) || currentDataValue.Value == null) throw new InvalidOperationException( $"Cannot infer target type for node {nodeId}: read returned status {currentDataValue.StatusCode} with no value. Provide a typed value instead of a string."); typedValue = ValueConverter.ConvertValue(rawString, currentDataValue.Value); } var dataValue = new DataValue(new Variant(typedValue)); return await _session!.WriteValueAsync(nodeId, dataValue, ct); } /// public async Task> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); var startNode = parentNodeId ?? ObjectIds.ObjectsFolder; var nodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable | (uint)NodeClass.Method; var results = new List(); var (continuationPoint, references) = await _session!.BrowseAsync(startNode, nodeClassMask, ct); while (references.Count > 0) { foreach (var reference in references) { var childNodeId = ExpandedNodeId.ToNodeId(reference.NodeId, _session.NamespaceUris); var hasChildren = reference.NodeClass == NodeClass.Object && await _session.HasChildrenAsync(childNodeId, ct); results.Add(new BrowseResult( reference.NodeId.ToString(), reference.DisplayName?.Text ?? string.Empty, reference.NodeClass.ToString(), hasChildren)); } if (continuationPoint is not { Length: > 0 } pending) break; (continuationPoint, references) = await _session.BrowseNextAsync(pending, ct); } return results; } /// public async Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); var nodeIdStr = nodeId.ToString(); lock (_subscriptionLock) { if (_activeDataSubscriptions.ContainsKey(nodeIdStr)) return; // Already subscribed } if (_dataSubscription == null) _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct); var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync( nodeId, intervalMs, OnDataChangeNotification, ct); lock (_subscriptionLock) { _activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle); } Logger.Debug("Subscribed to data changes on {NodeId}", nodeId); } /// public async Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default) { ThrowIfDisposed(); var nodeIdStr = nodeId.ToString(); (NodeId NodeId, int IntervalMs, uint Handle) sub; lock (_subscriptionLock) { if (!_activeDataSubscriptions.TryGetValue(nodeIdStr, out sub)) return; // Not subscribed, safe to ignore } if (_dataSubscription != null) await _dataSubscription.RemoveMonitoredItemAsync(sub.Handle, ct); lock (_subscriptionLock) { _activeDataSubscriptions.Remove(nodeIdStr); } Logger.Debug("Unsubscribed from data changes on {NodeId}", nodeId); } /// public async Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); // Serialise check-and-create under the semaphore: concurrent // callers cannot both pass the null-check and create duplicate alarm subscriptions. // The semaphore is async-friendly; the subscription I/O runs inside the critical section. await _alarmSubscribeSemaphore.WaitAsync(ct); try { if (_alarmSubscription != null) return; // Already subscribed to alarms var monitorNode = sourceNodeId ?? ObjectIds.Server; _alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, ct); var filter = CreateAlarmEventFilter(); await _alarmSubscription.AddEventMonitoredItemAsync( monitorNode, intervalMs, filter, OnAlarmEventNotification, ct); lock (_subscriptionLock) { _activeAlarmSubscription = (sourceNodeId, intervalMs); } Logger.Debug("Subscribed to alarm events on {NodeId}", monitorNode); } finally { _alarmSubscribeSemaphore.Release(); } } /// public async Task UnsubscribeAlarmsAsync(CancellationToken ct = default) { ThrowIfDisposed(); if (_alarmSubscription == null) return; await _alarmSubscription.DeleteAsync(ct); _alarmSubscription = null; lock (_subscriptionLock) { _activeAlarmSubscription = null; } Logger.Debug("Unsubscribed from alarm events"); } /// public async Task RequestConditionRefreshAsync(CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); if (_alarmSubscription == null) throw new InvalidOperationException("No alarm subscription is active."); await _alarmSubscription.ConditionRefreshAsync(ct); Logger.Debug("Condition refresh requested"); } /// public async Task AcknowledgeAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); // OtOpcUa conditions are their own event source (SourceNode == ConditionId), so the Part 9 methods are // invoked on that node directly. There is no ".Condition" child to append. var conditionObjId = NodeId.Parse(conditionNodeId); var acknowledgeMethodId = MethodIds.AcknowledgeableConditionType_Acknowledge; // CallMethodAsync throws ServiceResultException on a bad call result; // surface that as the returned StatusCode so callers using the documented // `Task` contract (e.g. `if (StatusCode.IsBad(result))`) see // the failure instead of an uncaught exception they did not anticipate. try { await _session!.CallMethodAsync( conditionObjId, acknowledgeMethodId, [eventId, new LocalizedText(comment)], ct); } catch (ServiceResultException ex) { Logger.Warning(ex, "Failed to acknowledge alarm on {ConditionId} (status {Status})", conditionNodeId, ex.StatusCode); return ex.StatusCode; } Logger.Debug("Acknowledged alarm on {ConditionId}", conditionNodeId); return StatusCodes.Good; } /// public async Task ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); // Confirm is invoked on the condition node itself, like Acknowledge. var conditionObjId = NodeId.Parse(conditionNodeId); var confirmMethodId = MethodIds.AcknowledgeableConditionType_Confirm; try { await _session!.CallMethodAsync( conditionObjId, confirmMethodId, [eventId, new LocalizedText(comment)], ct); } catch (ServiceResultException ex) { Logger.Warning(ex, "Failed to confirm alarm on {ConditionId} (status {Status})", conditionNodeId, ex.StatusCode); return ex.StatusCode; } Logger.Debug("Confirmed alarm on {ConditionId}", conditionNodeId); return StatusCodes.Good; } /// public async Task AddCommentAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); // AddComment is a ConditionType method, invoked on the condition node itself. var conditionObjId = NodeId.Parse(conditionNodeId); try { await _session!.CallMethodAsync( conditionObjId, MethodIds.ConditionType_AddComment, [eventId, new LocalizedText(comment)], ct); } catch (ServiceResultException ex) { Logger.Warning(ex, "Failed to add comment on {ConditionId} (status {Status})", conditionNodeId, ex.StatusCode); return ex.StatusCode; } Logger.Debug("Added comment on {ConditionId}", conditionNodeId); return StatusCodes.Good; } /// public async Task ShelveAlarmAsync(string conditionNodeId, ShelveKind kind, double shelvingTimeSeconds = 0, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); // The server's ShelvingState child is SDK-created with a numeric namespace-0 id, so a synthesized // ".ShelvingState" node id never exists. The shelve methods are called on the condition // object itself with the AlarmConditionType_ShelvingState_* method ids, which the server dispatches // to the condition's ShelvingState methods. var shelvingStateNodeId = NodeId.Parse(conditionNodeId); NodeId methodId; object[] inputArgs; switch (kind) { case ShelveKind.OneShot: methodId = MethodIds.AlarmConditionType_ShelvingState_OneShotShelve; inputArgs = []; break; case ShelveKind.Timed: if (shelvingTimeSeconds <= 0) throw new ArgumentOutOfRangeException(nameof(shelvingTimeSeconds), "Timed shelving requires a positive shelvingTimeSeconds value."); methodId = MethodIds.AlarmConditionType_ShelvingState_TimedShelve; // OPC UA Part 9 TimedShelve ShelvingTime input is a Duration (Double) in milliseconds. inputArgs = [shelvingTimeSeconds * 1000.0]; break; case ShelveKind.Unshelve: methodId = MethodIds.AlarmConditionType_ShelvingState_Unshelve; inputArgs = []; break; default: throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown ShelveKind value."); } try { await _session!.CallMethodAsync(shelvingStateNodeId, methodId, inputArgs, ct); } catch (ServiceResultException ex) { Logger.Warning(ex, "Failed to shelve alarm on {ConditionId} kind={Kind} (status {Status})", conditionNodeId, kind, ex.StatusCode); return ex.StatusCode; } Logger.Debug("Shelved alarm on {ConditionId} kind={Kind}", conditionNodeId, kind); return StatusCodes.Good; } /// public Task EnableAsync(string conditionNodeId, CancellationToken ct = default) => CallEnableDisableAsync(conditionNodeId, enabling: true, ct); /// public Task DisableAsync(string conditionNodeId, CancellationToken ct = default) => CallEnableDisableAsync(conditionNodeId, enabling: false, ct); /// /// Shared body for the H4 Enable/Disable client path. Invokes the OPC UA Part 9 ConditionType /// Enable/Disable method (no input arguments) on the condition object node, mirroring /// the convention: the methods are invoked on the condition /// node itself, and a bad call result surfaces as the returned rather than /// escaping as an uncaught . /// /// The condition node associated with the alarm. /// true to invoke Enable; false to invoke Disable. /// The cancellation token that aborts the request. private async Task CallEnableDisableAsync(string conditionNodeId, bool enabling, CancellationToken ct) { ThrowIfDisposed(); ThrowIfNotConnected(); // Enable/Disable are invoked on the condition node itself, like Acknowledge/Confirm. var conditionObjId = NodeId.Parse(conditionNodeId); var methodId = enabling ? MethodIds.ConditionType_Enable : MethodIds.ConditionType_Disable; try { await _session!.CallMethodAsync(conditionObjId, methodId, [], ct); } catch (ServiceResultException ex) { Logger.Warning(ex, "Failed to {Operation} alarm on {ConditionId} (status {Status})", enabling ? "enable" : "disable", conditionNodeId, ex.StatusCode); return ex.StatusCode; } Logger.Debug("{Operation} alarm on {ConditionId}", enabling ? "Enabled" : "Disabled", conditionNodeId); return StatusCodes.Good; } /// public async Task> HistoryReadRawAsync( NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); return await _session!.HistoryReadRawAsync(nodeId, startTime, endTime, maxValues, ct); } /// public async Task> HistoryReadModifiedAsync( NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues = 1000, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); return await _session!.HistoryReadModifiedAsync(nodeId, startTime, endTime, maxValues, ct); } /// public async Task HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); return await _session!.HistoryUpdateAsync(details, ct); } /// public async Task> HistoryReadAggregateAsync( NodeId nodeId, DateTime startTime, DateTime endTime, AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); var aggregateNodeId = AggregateTypeMapper.ToNodeId(aggregate); return await _session!.HistoryReadAggregateAsync(nodeId, startTime, endTime, aggregateNodeId, intervalMs, ct); } /// public async Task GetRedundancyInfoAsync(CancellationToken ct = default) { ThrowIfDisposed(); ThrowIfNotConnected(); var redundancySupportValue = await _session!.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport, ct); RedundancySupport redundancySupport; if (StatusCode.IsGood(redundancySupportValue.StatusCode) && redundancySupportValue.Value != null) redundancySupport = (RedundancySupport)Convert.ToInt32(redundancySupportValue.Value); else redundancySupport = RedundancySupport.None; var redundancyMode = redundancySupport.ToString(); var serviceLevelValue = await _session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct); var serviceLevel = StatusCode.IsGood(serviceLevelValue.StatusCode) && serviceLevelValue.Value != null ? Convert.ToByte(serviceLevelValue.Value) : (byte)0; string[] serverUris = []; try { var serverUriArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray, ct); if (serverUriArrayValue.Value is string[] uris) serverUris = uris; } catch { // ServerUriArray may not be present when RedundancySupport is None } var applicationUri = string.Empty; try { var serverArrayValue = await _session.ReadValueAsync(VariableIds.Server_ServerArray, ct); if (serverArrayValue.Value is string[] serverArray && serverArray.Length > 0) applicationUri = serverArray[0]; } catch { // Informational only } return new RedundancyInfo(redundancyMode, serviceLevel, serverUris, applicationUri); } /// /// Releases the current session and any active monitored-item subscriptions held by the client service. /// public void Dispose() { if (_disposed) return; _disposed = true; _dataSubscription?.Dispose(); _alarmSubscription?.Dispose(); _session?.Dispose(); _alarmSubscribeSemaphore.Dispose(); lock (_subscriptionLock) { _activeDataSubscriptions.Clear(); _activeAlarmSubscription = null; } CurrentConnectionInfo = null; _state = ConnectionState.Disconnected; } // --- Private helpers --- private async Task ConnectToEndpointAsync(ConnectionSettings settings, string endpointUrl, CancellationToken ct) { // Create a settings copy with the current endpoint URL var effectiveSettings = new ConnectionSettings { EndpointUrl = endpointUrl, SecurityMode = settings.SecurityMode, SessionTimeoutSeconds = settings.SessionTimeoutSeconds, AutoAcceptCertificates = settings.AutoAcceptCertificates, CertificateStorePath = settings.CertificateStorePath, Username = settings.Username, Password = settings.Password }; var config = await _configFactory.CreateAsync(effectiveSettings, ct); var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode); var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct); var identity = settings.Username != null ? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? "")) : new UserIdentity(); var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000); return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity, ct); } private async Task HandleKeepAliveFailureAsync() { // Serialize failover: the OPC UA stack raises KeepAlive repeatedly // while a session is down, so multiple bad keep-alives can fire before // the first failover loop finishes. CompareExchange atomically claims // the failover slot; a re-entrant call sees 1 and returns immediately, // guaranteeing exactly one failover loop runs at a time. if (Interlocked.CompareExchange(ref _failoverInProgress, 1, 0) != 0) return; try { await RunFailoverAsync(); } finally { Interlocked.Exchange(ref _failoverInProgress, 0); } } private async Task RunFailoverAsync() { if (_state == ConnectionState.Reconnecting || _state == ConnectionState.Disconnected) return; var oldEndpoint = _session?.EndpointUrl ?? string.Empty; TransitionState(ConnectionState.Reconnecting, oldEndpoint); Logger.Warning("Session lost on {EndpointUrl}. Attempting failover...", oldEndpoint); // Close old session if (_session != null) { try { _session.Dispose(); } catch { } _session = null; } _dataSubscription = null; _alarmSubscription = null; if (_settings == null || _allEndpointUrls == null) { TransitionState(ConnectionState.Disconnected, oldEndpoint); return; } // Try each endpoint for (var attempt = 0; attempt < _allEndpointUrls.Length; attempt++) { _currentEndpointIndex = (_currentEndpointIndex + 1) % _allEndpointUrls.Length; var url = _allEndpointUrls[_currentEndpointIndex]; try { Logger.Information("Failover attempt to {EndpointUrl}", url); var session = await ConnectToEndpointAsync(_settings, url, CancellationToken.None); _session = session; session.RegisterKeepAliveHandler(isGood => { if (!isGood) _ = HandleKeepAliveFailureAsync(); }); CurrentConnectionInfo = BuildConnectionInfo(session); TransitionState(ConnectionState.Connected, url); Logger.Information("Failover succeeded to {EndpointUrl}", url); // Replay subscriptions await ReplaySubscriptionsAsync(); return; } catch (Exception ex) { Logger.Warning(ex, "Failover to {EndpointUrl} failed", url); } } Logger.Error("All failover endpoints unreachable"); TransitionState(ConnectionState.Disconnected, oldEndpoint); } private async Task ReplaySubscriptionsAsync() { // Snapshot the bookkeeping state under the lock, then clear it so the // replayed handles can be recorded fresh as each monitored item is // re-created. Awaited calls run outside the lock. List> subscriptions; (NodeId? SourceNodeId, int IntervalMs)? alarmSubscription; lock (_subscriptionLock) { subscriptions = _activeDataSubscriptions.ToList(); alarmSubscription = _activeAlarmSubscription; _activeDataSubscriptions.Clear(); _activeAlarmSubscription = null; } // Replay data subscriptions foreach (var (nodeIdStr, (nodeId, intervalMs, _)) in subscriptions) try { if (_dataSubscription == null) _dataSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None); var handle = await _dataSubscription.AddDataChangeMonitoredItemAsync( nodeId, intervalMs, OnDataChangeNotification, CancellationToken.None); lock (_subscriptionLock) { _activeDataSubscriptions[nodeIdStr] = (nodeId, intervalMs, handle); } } catch (Exception ex) { Logger.Warning(ex, "Failed to replay data subscription for {NodeId}", nodeIdStr); } // Replay alarm subscription if (alarmSubscription.HasValue) { var (sourceNodeId, intervalMs) = alarmSubscription.Value; try { var monitorNode = sourceNodeId ?? ObjectIds.Server; _alarmSubscription = await _session!.CreateSubscriptionAsync(intervalMs, CancellationToken.None); var filter = CreateAlarmEventFilter(); await _alarmSubscription.AddEventMonitoredItemAsync( monitorNode, intervalMs, filter, OnAlarmEventNotification, CancellationToken.None); lock (_subscriptionLock) { _activeAlarmSubscription = (sourceNodeId, intervalMs); } } catch (Exception ex) { Logger.Warning(ex, "Failed to replay alarm subscription"); } } } private void OnDataChangeNotification(string nodeId, DataValue value) { DataChanged?.Invoke(this, new DataChangedEventArgs(nodeId, value)); } private void OnAlarmEventNotification(EventFieldList eventFields) { var fields = eventFields.EventFields; if (fields == null || fields.Count < 1) return; var eventId = fields.Count > 0 ? fields[0].Value as byte[] : null; var sourceName = fields.Count > 2 ? fields[2].Value as string ?? string.Empty : string.Empty; var time = DateTime.MinValue; if (fields.Count > 3 && fields[3].Value != null) { if (fields[3].Value is DateTime dt) time = dt; else if (DateTime.TryParse(fields[3].Value.ToString(), out var parsed)) time = parsed; } var message = fields.Count > 4 ? (fields[4].Value as LocalizedText)?.Text ?? string.Empty : string.Empty; var severity = fields.Count > 5 ? Convert.ToUInt16(fields[5].Value) : (ushort)0; var conditionName = fields.Count > 6 ? fields[6].Value as string ?? string.Empty : string.Empty; var retain = fields.Count > 7 && ParseBool(fields[7].Value); var conditionNodeId = fields.Count > 12 ? fields[12].Value?.ToString() : null; // Try standard OPC UA ActiveState/AckedState fields first bool? ackedField = fields.Count > 8 && fields[8].Value != null ? ParseBool(fields[8].Value) : null; bool? activeField = fields.Count > 9 && fields[9].Value != null ? ParseBool(fields[9].Value) : null; var ackedState = ackedField ?? false; var activeState = activeField ?? false; // No Galaxy-era supplemental reads (.InAlarm/.Acked/...): the server publishes the // standard Part 9 fields. A foreign server that leaves both null reports Active=false/Acked=false. AlarmEvent?.Invoke(this, new AlarmEventArgs( sourceName, conditionName, severity, message, retain, activeState, ackedState, time, eventId, conditionNodeId)); } private static bool ParseBool(object? value) { if (value == null) return false; if (value is bool b) return b; try { return Convert.ToBoolean(value); } catch { return false; } } internal static EventFilter CreateAlarmEventFilter() { var filter = new EventFilter(); // 0: EventId filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId); // 1: EventType filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventType); // 2: SourceName filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName); // 3: Time filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time); // 4: Message filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message); // 5: Severity filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity); // 6: ConditionName filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.ConditionName); // 7: Retain filter.AddSelectClause(ObjectTypeIds.ConditionType, BrowseNames.Retain); // 8-10: the two-state /Id booleans. Each browse path is TWO QualifiedNames. Passing "AckedState/Id" as a // string to AddSelectClause does not split it — it selects one element NAMED "AckedState/Id", which no // event carries, so all three arrived null and every alarm read inactive + unacknowledged. // 8: AckedState/Id filter.SelectClauses.Add(new SimpleAttributeOperand( ObjectTypeIds.AcknowledgeableConditionType, [BrowseNames.AckedState, BrowseNames.Id])); // 9: ActiveState/Id filter.SelectClauses.Add(new SimpleAttributeOperand( ObjectTypeIds.AlarmConditionType, [BrowseNames.ActiveState, BrowseNames.Id])); // 10: EnabledState/Id filter.SelectClauses.Add(new SimpleAttributeOperand( ObjectTypeIds.ConditionType, [BrowseNames.EnabledState, BrowseNames.Id])); // 11: SuppressedOrShelved filter.AddSelectClause(ObjectTypeIds.AlarmConditionType, "SuppressedOrShelved"); // 12: SourceNode (ConditionId for acknowledgment) filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceNode); return filter; } private static ConnectionInfo BuildConnectionInfo(ISessionAdapter session) { return new ConnectionInfo( session.EndpointUrl, session.ServerName, session.SecurityMode, session.SecurityPolicyUri, session.SessionId, session.SessionName); } private void TransitionState(ConnectionState newState, string endpointUrl) { var oldState = _state; if (oldState == newState) return; _state = newState; ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(oldState, newState, endpointUrl)); } private void ThrowIfDisposed() { if (_disposed) throw new ObjectDisposedException(nameof(OpcUaClientService)); } private void ThrowIfNotConnected() { if (_state != ConnectionState.Connected || _session == null) throw new InvalidOperationException("Not connected to an OPC UA server."); } }