using System.Collections.Concurrent; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Server.Alarms; /// /// Server-level alarm-condition state machine. Tracks one entry per registered /// condition; consumes value changes from the four sub-attribute references in /// (InAlarm / Priority / Description / Acked) and /// raises on Active / Acknowledged / Inactive /// transitions per OPC UA Part 9 (simplified). Operator acknowledgement routes /// through against AckMsgWriteRef. /// /// /// This is the driver-agnostic replacement for GalaxyAlarmTracker. The /// service does not own subscription lifecycle — PR 2.3 will wire DriverNodeManager /// to subscribe through the driver's ISubscribable and forward value changes /// here via . Keeping the service free of subscription /// plumbing makes it trivially testable and lets future drivers feed it from any /// value source (in-process, gRPC, named pipe). /// public sealed class AlarmConditionService : IDisposable { private readonly Func _clock; // ConditionId → state. private readonly ConcurrentDictionary _conditions = new(StringComparer.OrdinalIgnoreCase); // Sub-attribute full ref → (conditionId, which field). Multiple conditions may // observe the same sub-attribute (rare but legal); the value is a list to support // fan-out on a single value change. private readonly ConcurrentDictionary> _refToCondition = new(StringComparer.OrdinalIgnoreCase); private readonly object _refMapLock = new(); private bool _disposed; /// /// Fired when a registered condition transitions Active / Acknowledged / Inactive. /// Handlers must be cheap; the event is raised on whatever thread feeds /// and blocks the value-change pipeline. /// public event EventHandler? TransitionRaised; public AlarmConditionService() : this(() => DateTime.UtcNow) { } /// Test seam — inject a fixed clock for deterministic transition timestamps. internal AlarmConditionService(Func clock) { _clock = clock ?? throw new ArgumentNullException(nameof(clock)); } /// Number of currently tracked conditions. Diagnostic only. public int TrackedCount => _conditions.Count; /// /// Register a condition. Idempotent — repeat calls for the same /// are a no-op. The acker is captured for the /// condition's lifetime; pass null when the driver does not accept acks. /// public void Track(string conditionId, AlarmConditionInfo info, IAlarmAcknowledger? acker = null) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentException.ThrowIfNullOrWhiteSpace(conditionId); ArgumentNullException.ThrowIfNull(info); var state = new AlarmConditionState(conditionId, info, acker); if (!_conditions.TryAdd(conditionId, state)) return; lock (_refMapLock) { AddRefMapping(info.InAlarmRef, conditionId, AlarmField.InAlarm); AddRefMapping(info.PriorityRef, conditionId, AlarmField.Priority); AddRefMapping(info.DescAttrNameRef, conditionId, AlarmField.DescAttrName); AddRefMapping(info.AckedRef, conditionId, AlarmField.Acked); } } /// Deregister a condition. No-op when not tracked. public void Untrack(string conditionId) { if (_disposed) return; if (!_conditions.TryRemove(conditionId, out var state)) return; lock (_refMapLock) { RemoveRefMapping(state.Info.InAlarmRef, conditionId); RemoveRefMapping(state.Info.PriorityRef, conditionId); RemoveRefMapping(state.Info.DescAttrNameRef, conditionId); RemoveRefMapping(state.Info.AckedRef, conditionId); } } /// /// Returns the set of sub-attribute references the service currently needs /// subscribed. Callers wire one subscription per ref through the driver's /// ; PR 2.3 owns that wiring. /// public IReadOnlyCollection GetSubscribedReferences() { lock (_refMapLock) return [.. _refToCondition.Keys]; } /// /// Operator acknowledgement entry point. Returns false when the condition is /// not tracked, the condition has no acker registered, the condition has no /// AckMsgWriteRef, or the acker reports the write failed. /// public Task AcknowledgeAsync(string conditionId, string comment, CancellationToken cancellationToken = default) { if (_disposed || !_conditions.TryGetValue(conditionId, out var state)) return Task.FromResult(false); if (state.Acker is null || string.IsNullOrEmpty(state.Info.AckMsgWriteRef)) return Task.FromResult(false); return state.Acker.WriteAckMessageAsync(state.Info.AckMsgWriteRef, comment ?? string.Empty, cancellationToken); } /// /// Snapshot every tracked condition's current state. Diagnostic / dashboard use only. /// public IReadOnlyList Snapshot() { return [.. _conditions.Values.Select(s => { lock (s.Lock) return new AlarmConditionSnapshot(s.ConditionId, s.InAlarm, s.Acked, s.Priority, s.Description); })]; } /// /// Feed a value change for one of the registered sub-attribute references. /// The service runs the state machine and raises /// when the change produces a lifecycle transition. Unknown references are /// silently dropped — the caller may register and unregister concurrently with /// value-change delivery, and a stale callback for a recently-untracked /// condition must not throw. /// public void OnValueChanged(string fullReference, DataValueSnapshot value) { if (_disposed) return; if (string.IsNullOrEmpty(fullReference)) return; List<(string ConditionId, AlarmField Field)>? targets; lock (_refMapLock) { if (!_refToCondition.TryGetValue(fullReference, out targets) || targets.Count == 0) return; // Snapshot under lock; the state machine runs outside. targets = [.. targets]; } var now = _clock(); foreach (var (conditionId, field) in targets) { if (!_conditions.TryGetValue(conditionId, out var state)) continue; AlarmConditionTransition? transition = null; lock (state.Lock) { transition = ApplyValue(state, field, value, now); } if (transition is { } t) { TransitionRaised?.Invoke(this, t); } } } /// /// Apply one value change to one condition. Returns a transition when the /// change crosses a state boundary; null otherwise. Caller holds state.Lock. /// private static AlarmConditionTransition? ApplyValue( AlarmConditionState state, AlarmField field, DataValueSnapshot value, DateTime now) { AlarmConditionTransition? transition = null; state.LastUpdateUtc = now; switch (field) { case AlarmField.InAlarm: { var wasActive = state.InAlarm; var isActive = value.Value is bool b && b; state.InAlarm = isActive; if (!wasActive && isActive) { // Reset Acked on every active transition so a re-alarm requires fresh ack. state.Acked = false; transition = new AlarmConditionTransition( state.ConditionId, AlarmStateTransition.Active, state.Priority, state.Description, now); } else if (wasActive && !isActive) { transition = new AlarmConditionTransition( state.ConditionId, AlarmStateTransition.Inactive, state.Priority, state.Description, now); } break; } case AlarmField.Priority: state.Priority = CoercePriority(value.Value, state.Priority); break; case AlarmField.DescAttrName: state.Description = value.Value as string; break; case AlarmField.Acked: { var wasAcked = state.Acked; var isAcked = value.Value is bool b && b; state.Acked = isAcked; // Only fire Acknowledged on false → true while still active. The first // post-Track callback often arrives with isAcked == wasAcked (state starts // Acked=true so an initially-quiet alarm doesn't misfire). if (!wasAcked && isAcked && state.InAlarm) { transition = new AlarmConditionTransition( state.ConditionId, AlarmStateTransition.Acknowledged, state.Priority, state.Description, now); } break; } } return transition; } private static int CoercePriority(object? raw, int fallback) => raw switch { int i => i, short s => s, long l when l <= int.MaxValue => (int)l, byte b => b, ushort us => us, uint ui when ui <= int.MaxValue => (int)ui, _ => fallback, }; private void AddRefMapping(string? fullRef, string conditionId, AlarmField field) { if (string.IsNullOrEmpty(fullRef)) return; if (!_refToCondition.TryGetValue(fullRef, out var list)) { list = []; _refToCondition[fullRef] = list; } list.Add((conditionId, field)); } private void RemoveRefMapping(string? fullRef, string conditionId) { if (string.IsNullOrEmpty(fullRef)) return; if (!_refToCondition.TryGetValue(fullRef, out var list)) return; list.RemoveAll(t => string.Equals(t.ConditionId, conditionId, StringComparison.OrdinalIgnoreCase)); if (list.Count == 0) _refToCondition.TryRemove(fullRef, out _); } public void Dispose() { if (_disposed) return; _disposed = true; _conditions.Clear(); lock (_refMapLock) _refToCondition.Clear(); } private enum AlarmField { InAlarm, Priority, DescAttrName, Acked } /// Per-condition mutable state. Access guarded by . private sealed class AlarmConditionState(string conditionId, AlarmConditionInfo info, IAlarmAcknowledger? acker) { public readonly object Lock = new(); public string ConditionId { get; } = conditionId; public AlarmConditionInfo Info { get; } = info; public IAlarmAcknowledger? Acker { get; } = acker; public bool InAlarm; // Default Acked=true so the first post-Track callback (.Acked=true on a quiet // alarm) doesn't misfire as a transition. Active sets it back to false. public bool Acked = true; public int Priority; public string? Description; public DateTime LastUpdateUtc; } }