using System.Collections.Concurrent; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; /// /// Adapter that exposes through the driver-agnostic /// surface. The existing Phase 6.1 AlarmTracker /// composition fan-out consumes this alongside Galaxy / AB CIP / FOCAS alarm /// sources — no per-source branching in the fan-out. /// /// /// /// Per Phase 7 plan Stream C.6, ack / confirm / shelve / unshelve are OPC UA /// method calls per-condition. This adapter implements /// from the base interface; the richer Part 9 methods (Confirm / Shelve / /// Unshelve / AddComment) live directly on the engine, invoked from OPC UA /// method handlers wired up in Stream G. /// /// /// SubscribeAlarmsAsync takes a list of source-node-id filters (typically an /// Equipment path prefix). When the list is empty every alarm matches. The /// adapter doesn't maintain per-subscription state beyond the filter set — it /// checks each emission against every live subscription. /// /// public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable { private readonly ScriptedAlarmEngine _engine; private readonly ConcurrentDictionary _subscriptions = new(StringComparer.Ordinal); private bool _disposed; public ScriptedAlarmSource(ScriptedAlarmEngine engine) { _engine = engine ?? throw new ArgumentNullException(nameof(engine)); _engine.OnEvent += OnEngineEvent; } public event EventHandler? OnAlarmEvent; public Task SubscribeAlarmsAsync( IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) { if (sourceNodeIds is null) throw new ArgumentNullException(nameof(sourceNodeIds)); var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N")); _subscriptions[handle.DiagnosticId] = new Subscription(handle, new HashSet(sourceNodeIds, StringComparer.Ordinal)); return Task.FromResult(handle); } public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) { if (handle is null) throw new ArgumentNullException(nameof(handle)); _subscriptions.TryRemove(handle.DiagnosticId, out _); return Task.CompletedTask; } public async Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) { if (acknowledgements is null) throw new ArgumentNullException(nameof(acknowledgements)); foreach (var a in acknowledgements) { // The base interface doesn't carry a user identity — Stream G provides the // authenticated principal at the OPC UA dispatch layer + proxies through // the engine's richer AcknowledgeAsync. Here we default to "opcua-client" // so callers using the raw IAlarmSource still produce an audit entry. await _engine.AcknowledgeAsync(a.ConditionId, "opcua-client", a.Comment, cancellationToken) .ConfigureAwait(false); } } private void OnEngineEvent(object? sender, ScriptedAlarmEvent evt) { if (_disposed) return; foreach (var sub in _subscriptions.Values) { if (!Matches(sub, evt)) continue; var payload = new AlarmEventArgs( SubscriptionHandle: sub.Handle, SourceNodeId: evt.EquipmentPath, ConditionId: evt.AlarmId, AlarmType: evt.Kind.ToString(), Message: evt.Message, Severity: evt.Severity, SourceTimestampUtc: evt.TimestampUtc); try { OnAlarmEvent?.Invoke(this, payload); } catch { /* subscriber exceptions don't crash the adapter */ } } } private static bool Matches(Subscription sub, ScriptedAlarmEvent evt) { if (sub.Filter.Count == 0) return true; // A subscription matches if any filter is a prefix of the alarm's equipment // path — typical use is "Enterprise/Site/Area/Line" filtering a whole line. foreach (var f in sub.Filter) { if (evt.EquipmentPath.Equals(f, StringComparison.Ordinal)) return true; if (evt.EquipmentPath.StartsWith(f + "/", StringComparison.Ordinal)) return true; } return false; } public void Dispose() { if (_disposed) return; _disposed = true; _engine.OnEvent -= OnEngineEvent; _subscriptions.Clear(); } private sealed class SubscriptionHandle : IAlarmSubscriptionHandle { public SubscriptionHandle(string id) { DiagnosticId = id; } public string DiagnosticId { get; } } private sealed record Subscription(SubscriptionHandle Handle, IReadOnlySet Filter); }