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:
@@ -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 & 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)
|
||||
|
||||
Reference in New Issue
Block a user