diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs index ee7860cb..8f2ac598 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; @@ -81,6 +82,22 @@ public interface IOpcUaClient : IAsyncDisposable /// A task representing the asynchronous operation. Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default); + /// + /// Subscribes to OPC UA Alarms & Conditions events under + /// (or the Server object when null). On + /// (re)subscribe the adapter issues a ConditionRefresh and replays the + /// active conditions as Snapshot…SnapshotComplete transitions. Returns a + /// handle for . + /// + Task CreateAlarmSubscriptionAsync( + string? sourceNodeId, + string? conditionFilter, + Action onTransition, + CancellationToken cancellationToken = default); + + /// Removes an alarm-event subscription by handle. + Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default); + /// /// Reads the current value of a node. /// @@ -182,6 +199,19 @@ internal class StubOpcUaClient : IOpcUaClient return Task.CompletedTask; } + /// + public Task CreateAlarmSubscriptionAsync( + string? sourceNodeId, string? conditionFilter, + Action onTransition, CancellationToken cancellationToken = default) + { + // Stub: no events. Real A&C subscription lives in RealOpcUaClient. + return Task.FromResult(Guid.NewGuid().ToString()); + } + + /// + public Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default) + => Task.CompletedTask; + /// public Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync( string nodeId, CancellationToken cancellationToken = default) diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs index 665b2919..124e1c48 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs @@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; /// - Read/Write → Read/Write service calls /// - Quality → OPC UA StatusCode mapping /// -public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection +public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection { private readonly IOpcUaClientFactory _clientFactory; private readonly ILogger _logger; @@ -174,6 +174,27 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection cancellationToken); } + /// + public async Task SubscribeAlarmsAsync( + string sourceReference, string? conditionFilter, + AlarmTransitionCallback callback, CancellationToken cancellationToken = default) + { + EnsureConnected(); + // The client maps OPC UA A&C event fields → NativeAlarmTransition via + // OpcUaAlarmMapper and replays a snapshot on (re)subscribe. + return await _client!.CreateAlarmSubscriptionAsync( + sourceReference, conditionFilter, + transition => callback(transition), + cancellationToken); + } + + /// + public async Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default) + { + if (_client != null) + await _client.RemoveAlarmSubscriptionAsync(subscriptionId, cancellationToken); + } + /// public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default) { diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs index eec58c50..d24dfa85 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -5,6 +5,8 @@ using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; @@ -24,6 +26,13 @@ public class RealOpcUaClient : IOpcUaClient // Clear() is undefined behaviour, so they must be ConcurrentDictionary. private readonly ConcurrentDictionary _monitoredItems = new(); private readonly ConcurrentDictionary> _callbacks = new(); + + // Task-11: 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(); // DataConnectionLayer-013: int flag toggled with Interlocked.Exchange so the // once-only ConnectionLost guard in OnSessionKeepAlive is atomic, not just visible. // 0 = not fired, 1 = fired. @@ -220,6 +229,185 @@ public class RealOpcUaClient : IOpcUaClient } } + // ── Native alarm (Alarms & Conditions) subscription (Task-11) ── + // Behavioral correctness verified against a live A&C server in Task 28; 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 : NodeId.Parse(sourceNodeId); + var item = new MonitoredItem(_subscription.DefaultItem) + { + DisplayName = $"alarm:{sourceNodeId ?? "Server"}", + StartNodeId = startNode, + AttributeId = Attributes.EventNotifier, + MonitoringMode = MonitoringMode.Reporting, + SamplingInterval = 0, + QueueSize = 1000, + Filter = BuildAlarmEventFilter() + }; + + item.Notification += (_, e) => + { + if (e.NotificationValue is EventFieldList efl) + HandleAlarmEvent(handle, 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 _); + } + + /// + /// Builds the event filter selecting the base event fields plus the + /// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror. + /// + private static EventFilter BuildAlarmEventFilter() + { + 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 + return filter; + } + + 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, 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; + } + + var sourceName = fields[1].Value is NodeId ? (fields[2].Value as string ?? "") : (fields[2].Value as string ?? ""); + var conditionName = fields.Count > 11 ? fields[11].Value as string : null; + var sourceObjectRef = sourceName; + var sourceRef = string.IsNullOrEmpty(conditionName) ? sourceName : $"{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 ?? "" : ""; + + 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, + AlarmTypeName: eventType?.ToString() ?? "", + Kind: kind, + Condition: OpcUaAlarmMapper.BuildCondition(active, acked, confirmed, shelve, suppressed, severity), + Category: "", + Description: "", + Message: message, + OperatorUser: "", + OperatorComment: comment, + OriginalRaiseTime: null, + TransitionTime: time, + CurrentValue: "", + 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)