feat(dcl): OPC UA Alarms & Conditions adapter (event subscription + ConditionRefresh)

Adds IAlarmSubscribableConnection to OpcUaDataConnection, IOpcUaClient alarm
subscription methods, and RealOpcUaClient A&C event monitored-item +
EventFilter + ConditionRefresh snapshot, mapping fields via OpcUaAlarmMapper.
Behavior verified against a live A&C server in Task 28; mapper unit-tested.
This commit is contained in:
Joseph Doherty
2026-05-29 16:42:27 -04:00
parent 1fbb814daa
commit 0d30b7dec0
3 changed files with 240 additions and 1 deletions
@@ -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
/// <returns>A task representing the asynchronous operation.</returns>
Task RemoveSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default);
/// <summary>
/// Subscribes to OPC UA Alarms &amp; Conditions events under
/// <paramref name="sourceNodeId"/> (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 <see cref="RemoveAlarmSubscriptionAsync"/>.
/// </summary>
Task<string> CreateAlarmSubscriptionAsync(
string? sourceNodeId,
string? conditionFilter,
Action<NativeAlarmTransition> onTransition,
CancellationToken cancellationToken = default);
/// <summary>Removes an alarm-event subscription by handle.</summary>
Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default);
/// <summary>
/// Reads the current value of a node.
/// </summary>
@@ -182,6 +199,19 @@ internal class StubOpcUaClient : IOpcUaClient
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<string> CreateAlarmSubscriptionAsync(
string? sourceNodeId, string? conditionFilter,
Action<NativeAlarmTransition> onTransition, CancellationToken cancellationToken = default)
{
// Stub: no events. Real A&C subscription lives in RealOpcUaClient.
return Task.FromResult(Guid.NewGuid().ToString());
}
/// <inheritdoc />
public Task RemoveAlarmSubscriptionAsync(string subscriptionHandle, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
string nodeId, CancellationToken cancellationToken = default)
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// - Read/Write → Read/Write service calls
/// - Quality → OPC UA StatusCode mapping
/// </summary>
public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection
public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection
{
private readonly IOpcUaClientFactory _clientFactory;
private readonly ILogger<OpcUaDataConnection> _logger;
@@ -174,6 +174,27 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection
cancellationToken);
}
/// <inheritdoc />
public async Task<string> 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);
}
/// <inheritdoc />
public async Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
if (_client != null)
await _client.RemoveAlarmSubscriptionAsync(subscriptionId, cancellationToken);
}
/// <inheritdoc />
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
@@ -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<string, MonitoredItem> _monitoredItems = new();
private readonly ConcurrentDictionary<string, Action<string, object?, DateTime, uint>> _callbacks = new();
// Task-11: native alarm (A&C) event subscriptions, keyed by handle.
private readonly ConcurrentDictionary<string, MonitoredItem> _alarmItems = new();
// Per-handle "currently inside a ConditionRefresh replay" flag → Snapshot kind.
private readonly ConcurrentDictionary<string, bool> _alarmInRefresh = new();
// Per-handle last (active, acked) by source reference, to derive transition kind.
private readonly ConcurrentDictionary<string, Dictionary<string, (bool Active, bool Acked)>> _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"];
/// <inheritdoc />
public async Task<string> CreateAlarmSubscriptionAsync(
string? sourceNodeId, string? conditionFilter,
Action<NativeAlarmTransition> 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<string, (bool, bool)>(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;
}
/// <inheritdoc />
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 _);
}
/// <summary>
/// Builds the event filter selecting the base event fields plus the
/// AlarmConditionType / AcknowledgeableConditionType state sub-variables we mirror.
/// </summary>
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<NativeAlarmTransition> 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, "", "");
/// <inheritdoc />
public async Task<(object? Value, DateTime SourceTimestamp, uint StatusCode)> ReadValueAsync(
string nodeId, CancellationToken cancellationToken = default)