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)