using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms; /// /// Subscribes to the four Galaxy alarm attributes (.InAlarm, .Priority, /// .DescAttrName, .Acked) per alarm-bearing attribute discovered during /// DiscoverAsync. Maintains one per alarm, raises /// on lifecycle transitions (Active / Unacknowledged / /// Acknowledged / Inactive). Ack path writes .AckMsg. Pure-logic state machine /// with delegate-based subscribe/write so it's testable against in-memory fakes. /// /// /// Transitions emitted (OPC UA Part 9 alarm lifecycle, simplified for the Galaxy model): /// /// Active — InAlarm false → true. Default to Unacknowledged. /// Acknowledged — Acked false → true while InAlarm is still true. /// Inactive — InAlarm true → false. If still unacknowledged the alarm /// is marked latched-inactive-unack; next Ack transitions straight to Inactive. /// /// public sealed class GalaxyAlarmTracker : IDisposable { public const string InAlarmAttr = ".InAlarm"; public const string PriorityAttr = ".Priority"; public const string DescAttrNameAttr = ".DescAttrName"; public const string AckedAttr = ".Acked"; public const string AckMsgAttr = ".AckMsg"; private readonly Func, Task> _subscribe; private readonly Func _unsubscribe; private readonly Func> _write; private readonly Func _clock; // Alarm tag (attribute full ref, e.g. "Tank.Level.HiHi") → state. private readonly ConcurrentDictionary _alarms = new(StringComparer.OrdinalIgnoreCase); // Reverse lookup: probed tag (".InAlarm" etc.) → owning alarm tag. private readonly ConcurrentDictionary _probeToAlarm = new(StringComparer.OrdinalIgnoreCase); private bool _disposed; public event EventHandler? TransitionRaised; public GalaxyAlarmTracker( Func, Task> subscribe, Func unsubscribe, Func> write) : this(subscribe, unsubscribe, write, () => DateTime.UtcNow) { } internal GalaxyAlarmTracker( Func, Task> subscribe, Func unsubscribe, Func> write, Func clock) { _subscribe = subscribe ?? throw new ArgumentNullException(nameof(subscribe)); _unsubscribe = unsubscribe ?? throw new ArgumentNullException(nameof(unsubscribe)); _write = write ?? throw new ArgumentNullException(nameof(write)); _clock = clock ?? throw new ArgumentNullException(nameof(clock)); } public int TrackedAlarmCount => _alarms.Count; /// /// Advise the four alarm attributes for . Idempotent — /// repeat calls for the same alarm tag are a no-op. Subscribe failure for any of the /// four rolls back the alarm entry so a stale callback cannot promote a phantom. /// public async Task TrackAsync(string alarmTag) { if (_disposed || string.IsNullOrWhiteSpace(alarmTag)) return; if (_alarms.ContainsKey(alarmTag)) return; var state = new AlarmState { AlarmTag = alarmTag }; if (!_alarms.TryAdd(alarmTag, state)) return; var probes = new[] { (Tag: alarmTag + InAlarmAttr, Field: AlarmField.InAlarm), (Tag: alarmTag + PriorityAttr, Field: AlarmField.Priority), (Tag: alarmTag + DescAttrNameAttr, Field: AlarmField.DescAttrName), (Tag: alarmTag + AckedAttr, Field: AlarmField.Acked), }; foreach (var p in probes) { _probeToAlarm[p.Tag] = (alarmTag, p.Field); } try { foreach (var p in probes) { await _subscribe(p.Tag, OnProbeCallback).ConfigureAwait(false); } } catch { // Rollback so a partial advise doesn't leak state. _alarms.TryRemove(alarmTag, out _); foreach (var p in probes) { _probeToAlarm.TryRemove(p.Tag, out _); try { await _unsubscribe(p.Tag).ConfigureAwait(false); } catch { } } throw; } } /// /// Drop every tracked alarm. Unadvises all 4 probes per alarm as best-effort. /// public async Task ClearAsync() { _alarms.Clear(); foreach (var kv in _probeToAlarm.ToList()) { _probeToAlarm.TryRemove(kv.Key, out _); try { await _unsubscribe(kv.Key).ConfigureAwait(false); } catch { } } } /// /// Operator ack — write the comment text into <alarmTag>.AckMsg. /// Returns false when the runtime reports the write failed. /// public Task AcknowledgeAsync(string alarmTag, string comment) { if (_disposed || string.IsNullOrWhiteSpace(alarmTag)) return Task.FromResult(false); return _write(alarmTag + AckMsgAttr, comment ?? string.Empty); } /// /// Subscription callback entry point. Exposed for tests and for the Backend to route /// fan-out callbacks through. Runs the state machine and fires TransitionRaised /// outside the lock. /// public void OnProbeCallback(string probeTag, Vtq vtq) { if (_disposed) return; if (!_probeToAlarm.TryGetValue(probeTag, out var link)) return; if (!_alarms.TryGetValue(link.AlarmTag, out var state)) return; AlarmTransition? transition = null; var now = _clock(); lock (state.Lock) { switch (link.Field) { case AlarmField.InAlarm: { var wasActive = state.InAlarm; var isActive = vtq.Value is bool b && b; state.InAlarm = isActive; state.LastUpdateUtc = now; if (!wasActive && isActive) { state.Acked = false; state.LastTransitionUtc = now; transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Active, state.Priority, state.DescAttrName, now); } else if (wasActive && !isActive) { state.LastTransitionUtc = now; transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Inactive, state.Priority, state.DescAttrName, now); } break; } case AlarmField.Priority: if (vtq.Value is int pi) state.Priority = pi; else if (vtq.Value is short ps) state.Priority = ps; else if (vtq.Value is long pl && pl <= int.MaxValue) state.Priority = (int)pl; state.LastUpdateUtc = now; break; case AlarmField.DescAttrName: state.DescAttrName = vtq.Value as string; state.LastUpdateUtc = now; break; case AlarmField.Acked: { var wasAcked = state.Acked; var isAcked = vtq.Value is bool b && b; state.Acked = isAcked; state.LastUpdateUtc = now; // Fire Acknowledged only when transitioning false→true. Don't fire on initial // subscribe callback (wasAcked==isAcked in that case because the state starts // with Acked=false and the initial probe is usually true for an un-active alarm). if (!wasAcked && isAcked && state.InAlarm) { state.LastTransitionUtc = now; transition = new AlarmTransition(state.AlarmTag, AlarmStateTransition.Acknowledged, state.Priority, state.DescAttrName, now); } break; } } } if (transition is { } t) { TransitionRaised?.Invoke(this, t); } } public IReadOnlyList SnapshotStates() { return _alarms.Values.Select(s => { lock (s.Lock) return new AlarmSnapshot(s.AlarmTag, s.InAlarm, s.Acked, s.Priority, s.DescAttrName); }).ToList(); } public void Dispose() { if (_disposed) return; _disposed = true; _alarms.Clear(); _probeToAlarm.Clear(); } private sealed class AlarmState { public readonly object Lock = new(); public string AlarmTag = ""; public bool InAlarm; public bool Acked = true; // default ack'd so first false→true on subscribe doesn't misfire public int Priority; public string? DescAttrName; public DateTime LastUpdateUtc; public DateTime LastTransitionUtc; } private enum AlarmField { InAlarm, Priority, DescAttrName, Acked } } public enum AlarmStateTransition { Active, Acknowledged, Inactive } public sealed record AlarmTransition( string AlarmTag, AlarmStateTransition Transition, int Priority, string? DescAttrName, DateTime AtUtc); public sealed record AlarmSnapshot( string AlarmTag, bool InAlarm, bool Acked, int Priority, string? DescAttrName);