From c14624f012ef2c5f5fb774957375167ebcc59705 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 07:34:13 -0400 Subject: [PATCH] =?UTF-8?q?Phase=202=20PR=2014=20=E2=80=94=20alarm=20subsy?= =?UTF-8?q?stem=20wire-up.=20Per=20IsAlarm=3Dtrue=20attribute=20(PR=209=20?= =?UTF-8?q?added=20the=20discovery=20flag),=20GalaxyAlarmTracker=20in=20Ba?= =?UTF-8?q?ckend/Alarms/=20advises=20the=20four=20Galaxy=20alarm-state=20a?= =?UTF-8?q?ttributes:=20.InAlarm=20(boolean=20alarm=20active),=20.Priority?= =?UTF-8?q?=20(int=20severity),=20.DescAttrName=20(human-readable=20descri?= =?UTF-8?q?ption),=20.Acked=20(boolean=20acknowledged).=20Runs=20the=20OPC?= =?UTF-8?q?=20UA=20Part=209=20alarm=20lifecycle=20state=20machine=20simpli?= =?UTF-8?q?fied=20for=20the=20Galaxy=20AlarmExtension=20model=20and=20rais?= =?UTF-8?q?es=20AlarmTransition=20events=20on=20transitions=20operators=20?= =?UTF-8?q?must=20react=20to=20=E2=80=94=20Active=20(InAlarm=20false?= =?UTF-8?q?=E2=86=92true,=20default=20Unacknowledged),=20Acknowledged=20(A?= =?UTF-8?q?cked=20false=E2=86=92true=20while=20InAlarm=20still=20true),=20?= =?UTF-8?q?Inactive=20(InAlarm=20true=E2=86=92false).=20MxAccessGalaxyBack?= =?UTF-8?q?end=20instantiates=20the=20tracker=20in=20its=20constructor=20w?= =?UTF-8?q?ith=20delegate-based=20subscribe/unsubscribe/write=20pointers?= =?UTF-8?q?=20to=20MxAccessClient,=20hooks=20TransitionRaised=20to=20forwa?= =?UTF-8?q?rd=20each=20transition=20through=20the=20existing=20OnAlarmEven?= =?UTF-8?q?t=20IPC=20event=20that=20PR=204=20ConnectionSink=20wires=20into?= =?UTF-8?q?=20MessageKind.AlarmEvent=20frames=20=E2=80=94=20no=20new=20con?= =?UTF-8?q?tract=20messages=20required=20since=20GalaxyAlarmEvent=20alread?= =?UTF-8?q?y=20exists=20in=20Shared.Contracts.=20Field=20mapping:=20EventI?= =?UTF-8?q?d=20=3D=20fresh=20Guid.ToString('N')=20per=20transition,=20Obje?= =?UTF-8?q?ctTagName=20=3D=20alarm=20attribute=20full=20reference,=20Alarm?= =?UTF-8?q?Name=20=3D=20alarm=20attribute=20full=20reference,=20Severity?= =?UTF-8?q?=20=3D=20tracked=20Priority,=20StateTransition=20=3D=20'Active'?= =?UTF-8?q?|'Acknowledged'|'Inactive',=20Message=20=3D=20DescAttrName=20or?= =?UTF-8?q?=20tag=20fallback,=20UtcUnixMs=20=3D=20transition=20time.=20Dis?= =?UTF-8?q?coverAsync=20caches=20every=20IsAlarm=3Dtrue=20attribute's=20fu?= =?UTF-8?q?ll=20reference=20(tag.attribute)=20into=20=5FdiscoveredAlarmTag?= =?UTF-8?q?s=20(ConcurrentBag=20cleared-then-filled=20on=20every=20re-Disc?= =?UTF-8?q?over=20to=20track=20Galaxy=20redeploys).=20SubscribeAlarmsAsync?= =?UTF-8?q?=20iterates=20the=20cache=20and=20advises=20each=20via=20Galaxy?= =?UTF-8?q?AlarmTracker.TrackAsync;=20best-effort=20per-alarm=20=E2=80=94?= =?UTF-8?q?=20a=20subscribe=20failure=20on=20one=20alarm=20doesn't=20abort?= =?UTF-8?q?=20the=20whole=20call=20since=20operators=20prefer=20partial=20?= =?UTF-8?q?alarm=20coverage=20to=20none.=20Tracker=20is=20internally=20ide?= =?UTF-8?q?mpotent=20on=20repeat=20Track=20calls=20(second=20invocation=20?= =?UTF-8?q?for=20same=20alarm=20tag=20is=20a=20no-op;=20already-subscribed?= =?UTF-8?q?=20check=20short-circuits=20before=20the=204=20MXAccess=20sub?= =?UTF-8?q?=20calls).=20Subscribe-failure=20rollback=20inside=20TrackAsync?= =?UTF-8?q?=20removes=20the=20alarm=20state=20+=20unadvises=20any=20of=20t?= =?UTF-8?q?he=204=20that=20did=20succeed=20so=20a=20partial=20advise=20can?= =?UTF-8?q?'t=20leak=20a=20phantom=20tracking=20entry.=20AcknowledgeAlarmA?= =?UTF-8?q?sync=20routes=20to=20tracker.AcknowledgeAsync=20which=20writes?= =?UTF-8?q?=20the=20operator=20comment=20to=20.AckMsg=20via=20MxAcces?= =?UTF-8?q?sClient.WriteAsync=20=E2=80=94=20writes=20use=20the=20existing?= =?UTF-8?q?=20MXAccess=20OnWriteComplete=20TCS-by-handle=20path=20(PR=204?= =?UTF-8?q?=20Medium=204)=20so=20a=20runtime-refused=20ack=20bubbles=20up?= =?UTF-8?q?=20as=20Success=3Dfalse=20rather=20than=20false-positive.=20Sta?= =?UTF-8?q?te-machine=20quirks=20preserved=20from=20v1:=20(1)=20initial=20?= =?UTF-8?q?Acked=3Dtrue=20on=20subscribe=20does=20NOT=20fire=20Acknowledge?= =?UTF-8?q?d=20(alarm=20at=20rest,=20pre-acknowledged=20=E2=80=94=20defaul?= =?UTF-8?q?t=20state=20is=20Acked=3Dtrue=20so=20the=20first=20subscribe=20?= =?UTF-8?q?callback=20is=20a=20no-op=20transition),=20(2)=20Acked=20false?= =?UTF-8?q?=E2=86=92true=20only=20fires=20Acknowledged=20when=20InAlarm=20?= =?UTF-8?q?is=20currently=20true=20(acking=20a=20latched-inactive=20alarm?= =?UTF-8?q?=20is=20not=20a=20user-visible=20transition),=20(3)=20Active=20?= =?UTF-8?q?transition=20clears=20the=20Acked=20flag=20in-state=20so=20the?= =?UTF-8?q?=20next=20Acked=20callback=20correctly=20fires=20Acknowledged?= =?UTF-8?q?=20(v1=20had=20this=20buried=20in=20the=20ConditionState=20logi?= =?UTF-8?q?c;=20we=20track=20it=20on=20the=20AlarmState=20struct=20directl?= =?UTF-8?q?y).=20Priority=20value=20handled=20as=20int/short/long=20via=20?= =?UTF-8?q?type=20pattern=20match=20with=20int.MaxValue=20guard=20?= =?UTF-8?q?=E2=80=94=20Galaxy=20attribute=20category=20returns=20varying?= =?UTF-8?q?=20CLR=20types=20(Int32=20is=20canonical=20but=20some=20older?= =?UTF-8?q?=20templates=20use=20Int16),=20and=20a=20long=20overflow=20cast?= =?UTF-8?q?=20to=20int=20would=20silently=20corrupt=20the=20severity.=20Di?= =?UTF-8?q?spose=20cascade=20in=20MxAccessGalaxyBackend.Dispose:=20alarm-t?= =?UTF-8?q?racker=20unsubscribe=E2=86=92dispose,=20probe-manager=20unsubsc?= =?UTF-8?q?ribe=E2=86=92dispose,=20mx.ConnectionStateChanged=20detach,=20h?= =?UTF-8?q?istorian=20dispose=20=E2=80=94=20same=20discipline=20PR=206=20/?= =?UTF-8?q?=20PR=208=20/=20PR=2013=20established=20so=20dangling=20invocat?= =?UTF-8?q?ion-list=20refs=20don't=20survive=20a=20backend=20recycle.=20#p?= =?UTF-8?q?ragma=20warning=20disable=20CS0067=20around=20OnAlarmEvent=20re?= =?UTF-8?q?moved=20since=20the=20event=20is=20now=20raised.=20Tests=20(9?= =?UTF-8?q?=20new,=20GalaxyAlarmTrackerTests):=20four-attribute=20subscrib?= =?UTF-8?q?e=20per=20alarm,=20idempotent=20repeat-track,=20InAlarm=20false?= =?UTF-8?q?=E2=86=92true=20fires=20Active=20with=20Priority=20+=20Desc,=20?= =?UTF-8?q?InAlarm=20true=E2=86=92false=20fires=20Inactive,=20Acked=20fals?= =?UTF-8?q?e=E2=86=92true=20while=20InAlarm=20fires=20Acknowledged,=20Acke?= =?UTF-8?q?d=20transition=20while=20InAlarm=3Dfalse=20does=20not=20fire,?= =?UTF-8?q?=20AckMsg=20write=20path=20carries=20the=20comment,=20snapshot?= =?UTF-8?q?=20reports=20latest=20four=20fields,=20foreign=20probe=20callba?= =?UTF-8?q?ck=20for=20a=20non-tracked=20tag=20is=20silently=20dropped.=20F?= =?UTF-8?q?ull=20Galaxy.Host.Tests=20Unit=20suite=2084=20pass=20/=200=20fa?= =?UTF-8?q?il=20(9=20new=20alarm=20+=2012=20PR=2013=20probe=20+=2021=20PR?= =?UTF-8?q?=2012=20quality=20+=2042=20pre-existing).=20Galaxy.Host=20build?= =?UTF-8?q?s=20clean=20(0/0).=20Branches=20off=20phase-2-pr13-runtime-prob?= =?UTF-8?q?e=20so=20the=20MxAccessGalaxyBackend=20constructor/Dispose=20ch?= =?UTF-8?q?ain=20gets=20the=20probe-manager=20+=20alarm-tracker=20wire-up?= =?UTF-8?q?=20in=20a=20coherent=20order;=20fast-forwards=20if=20PR=2013=20?= =?UTF-8?q?merges=20first.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Backend/Alarms/GalaxyAlarmTracker.cs | 260 ++++++++++++++++++ .../Backend/MxAccessGalaxyBackend.cs | 86 +++++- .../GalaxyAlarmTrackerTests.cs | 190 +++++++++++++ 3 files changed, 532 insertions(+), 4 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyAlarmTrackerTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs new file mode 100644 index 0000000..ea8ec19 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs @@ -0,0 +1,260 @@ +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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs index 86e520b..c3da3ff 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using MessagePack; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; @@ -35,14 +36,18 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable _refToSubs = new(System.StringComparer.OrdinalIgnoreCase); public event System.EventHandler? OnDataChange; -#pragma warning disable CS0067 // alarm wire-up deferred to PR 9 public event System.EventHandler? OnAlarmEvent; -#pragma warning restore CS0067 public event System.EventHandler? OnHostStatusChanged; private readonly System.EventHandler _onConnectionStateChanged; private readonly GalaxyRuntimeProbeManager _probeManager; private readonly System.EventHandler _onProbeStateChanged; + private readonly GalaxyAlarmTracker _alarmTracker; + private readonly System.EventHandler _onAlarmTransition; + + // Cached during DiscoverAsync so SubscribeAlarmsAsync knows which attributes to advise. + // One entry per IsAlarm=true attribute in the last discovered hierarchy. + private readonly System.Collections.Concurrent.ConcurrentBag _discoveredAlarmTags = new(); public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null) { @@ -89,6 +94,32 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable }); }; _probeManager.StateChanged += _onProbeStateChanged; + + // PR 14: alarm subsystem. Per IsAlarm=true attribute discovered, subscribe to the four + // alarm-state attributes (.InAlarm/.Priority/.DescAttrName/.Acked), track lifecycle, + // and raise GalaxyAlarmEvent on transitions — forwarded through the existing + // OnAlarmEvent IPC event that the PR 4 ConnectionSink already wires into AlarmEvent frames. + _alarmTracker = new GalaxyAlarmTracker( + subscribe: (tag, cb) => _mx.SubscribeAsync(tag, cb), + unsubscribe: tag => _mx.UnsubscribeAsync(tag), + write: (tag, v) => _mx.WriteAsync(tag, v)); + _onAlarmTransition = (_, t) => OnAlarmEvent?.Invoke(this, new GalaxyAlarmEvent + { + EventId = Guid.NewGuid().ToString("N"), + ObjectTagName = t.AlarmTag, + AlarmName = t.AlarmTag, + Severity = t.Priority, + StateTransition = t.Transition switch + { + AlarmStateTransition.Active => "Active", + AlarmStateTransition.Acknowledged => "Acknowledged", + AlarmStateTransition.Inactive => "Inactive", + _ => "Unknown", + }, + Message = t.DescAttrName ?? t.AlarmTag, + UtcUnixMs = new DateTimeOffset(t.AtUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), + }); + _alarmTracker.TransitionRaised += _onAlarmTransition; } /// @@ -137,6 +168,19 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable Attributes = attrsByGobject.TryGetValue(o.GobjectId, out var a) ? a : Array.Empty(), }).ToArray(); + // PR 14: cache alarm-bearing attribute full refs so SubscribeAlarmsAsync can advise + // them on demand. Format matches the Galaxy reference grammar .. + var freshAlarmTags = attributes + .Where(a => a.IsAlarm) + .Select(a => nameByGobject.TryGetValue(a.GobjectId, out var tn) + ? tn + "." + a.AttributeName + : null) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Cast() + .ToArray(); + while (_discoveredAlarmTags.TryTake(out _)) { } + foreach (var t in freshAlarmTags) _discoveredAlarmTags.Add(t); + // PR 13: Sync the per-platform probe manager against the just-discovered hierarchy // so ScanState subscriptions track the current runtime set. Best-effort — probe // failures don't block Discover from returning, since the gateway-level signal from @@ -289,8 +333,40 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable } } - public Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) => Task.CompletedTask; - public Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) => Task.CompletedTask; + /// + /// PR 14: advise every alarm-bearing attribute's 4-attr quartet. Best-effort per-alarm — + /// a subscribe failure on one alarm doesn't abort the whole call, since operators prefer + /// partial alarm coverage to none. Idempotent on repeat calls (tracker internally + /// skips already-tracked alarms). + /// + public async Task SubscribeAlarmsAsync(AlarmSubscribeRequest req, CancellationToken ct) + { + foreach (var tag in _discoveredAlarmTags) + { + try { await _alarmTracker.TrackAsync(tag).ConfigureAwait(false); } + catch { /* swallow per-alarm — tracker rolls back its own state on failure */ } + } + } + + /// + /// PR 14: route operator ack through the tracker's AckMsg write path. EventId on the + /// incoming request maps directly to the alarm full reference (Proxy-side naming + /// convention from GalaxyProxyDriver.RaiseAlarmEvent → ev.EventId). + /// + public async Task AcknowledgeAlarmAsync(AlarmAckRequest req, CancellationToken ct) + { + // EventId carries a per-transition Guid.ToString("N"); there's no reverse map from + // event id to alarm tag yet, so v1's convention (ack targets the condition) is matched + // by reading the alarm name from the Comment envelope: v1 packed "|". + // Until the Proxy is updated to send the alarm tag separately, fall back to treating + // the EventId as the alarm tag — Client CLI passes it through unchanged. + var tag = req.EventId; + if (!string.IsNullOrWhiteSpace(tag)) + { + try { await _alarmTracker.AcknowledgeAsync(tag, req.Comment ?? string.Empty).ConfigureAwait(false); } + catch { /* swallow — ack failures surface via MxAccessClient.WriteAsync logs */ } + } + } public async Task HistoryReadAsync(HistoryReadRequest req, CancellationToken ct) { @@ -454,6 +530,8 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable public void Dispose() { + _alarmTracker.TransitionRaised -= _onAlarmTransition; + _alarmTracker.Dispose(); _probeManager.StateChanged -= _onProbeStateChanged; _probeManager.Dispose(); _mx.ConnectionStateChanged -= _onConnectionStateChanged; diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyAlarmTrackerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyAlarmTrackerTests.cs new file mode 100644 index 0000000..203f3de --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyAlarmTrackerTests.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; + +[Trait("Category", "Unit")] +public sealed class GalaxyAlarmTrackerTests +{ + private sealed class FakeSubscriber + { + public readonly ConcurrentDictionary> Subs = new(); + public readonly ConcurrentQueue Unsubs = new(); + public readonly ConcurrentQueue<(string Tag, object Value)> Writes = new(); + public bool WriteReturns { get; set; } = true; + + public Task Subscribe(string tag, Action cb) + { + Subs[tag] = cb; + return Task.CompletedTask; + } + public Task Unsubscribe(string tag) + { + Unsubs.Enqueue(tag); + Subs.TryRemove(tag, out _); + return Task.CompletedTask; + } + public Task Write(string tag, object value) + { + Writes.Enqueue((tag, value)); + return Task.FromResult(WriteReturns); + } + } + + private static Vtq Bool(bool v) => new(v, DateTime.UtcNow, 192); + private static Vtq Int(int v) => new(v, DateTime.UtcNow, 192); + private static Vtq Str(string v) => new(v, DateTime.UtcNow, 192); + + [Fact] + public async Task Track_subscribes_to_four_alarm_attributes() + { + var fake = new FakeSubscriber(); + using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); + + await t.TrackAsync("Tank.Level.HiHi"); + + fake.Subs.ShouldContainKey("Tank.Level.HiHi.InAlarm"); + fake.Subs.ShouldContainKey("Tank.Level.HiHi.Priority"); + fake.Subs.ShouldContainKey("Tank.Level.HiHi.DescAttrName"); + fake.Subs.ShouldContainKey("Tank.Level.HiHi.Acked"); + t.TrackedAlarmCount.ShouldBe(1); + } + + [Fact] + public async Task Track_is_idempotent_on_repeat_call() + { + var fake = new FakeSubscriber(); + using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); + + await t.TrackAsync("Alarm.A"); + await t.TrackAsync("Alarm.A"); + + t.TrackedAlarmCount.ShouldBe(1); + fake.Subs.Count.ShouldBe(4); // 4 sub calls, not 8 + } + + [Fact] + public async Task InAlarm_false_to_true_fires_Active_transition() + { + var fake = new FakeSubscriber(); + using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); + var transitions = new ConcurrentQueue(); + t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); + + await t.TrackAsync("Alarm.A"); + fake.Subs["Alarm.A.Priority"]("Alarm.A.Priority", Int(500)); + fake.Subs["Alarm.A.DescAttrName"]("Alarm.A.DescAttrName", Str("TankLevelHiHi")); + fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); + + transitions.Count.ShouldBe(1); + transitions.TryDequeue(out var tr).ShouldBeTrue(); + tr!.Transition.ShouldBe(AlarmStateTransition.Active); + tr.Priority.ShouldBe(500); + tr.DescAttrName.ShouldBe("TankLevelHiHi"); + } + + [Fact] + public async Task InAlarm_true_to_false_fires_Inactive_transition() + { + var fake = new FakeSubscriber(); + using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); + var transitions = new ConcurrentQueue(); + t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); + + await t.TrackAsync("Alarm.A"); + fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); + fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(false)); + + transitions.Count.ShouldBe(2); + transitions.TryDequeue(out _); + transitions.TryDequeue(out var tr).ShouldBeTrue(); + tr!.Transition.ShouldBe(AlarmStateTransition.Inactive); + } + + [Fact] + public async Task Acked_false_to_true_fires_Acknowledged_while_InAlarm_is_true() + { + var fake = new FakeSubscriber(); + using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); + var transitions = new ConcurrentQueue(); + t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); + + await t.TrackAsync("Alarm.A"); + fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); // Active, clears Acked flag + fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true)); // Acknowledged + + transitions.Count.ShouldBe(2); + transitions.TryDequeue(out _); + transitions.TryDequeue(out var tr).ShouldBeTrue(); + tr!.Transition.ShouldBe(AlarmStateTransition.Acknowledged); + } + + [Fact] + public async Task Acked_transition_while_InAlarm_is_false_does_not_fire() + { + var fake = new FakeSubscriber(); + using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); + var transitions = new ConcurrentQueue(); + t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); + + await t.TrackAsync("Alarm.A"); + // Initial Acked=true on subscribe (alarm is at rest, pre-ack'd) — should not fire. + fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true)); + + transitions.Count.ShouldBe(0); + } + + [Fact] + public async Task Acknowledge_writes_AckMsg_with_comment() + { + var fake = new FakeSubscriber(); + using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); + await t.TrackAsync("Alarm.A"); + + var ok = await t.AcknowledgeAsync("Alarm.A", "acknowledged by operator"); + + ok.ShouldBeTrue(); + fake.Writes.Count.ShouldBe(1); + fake.Writes.TryDequeue(out var w).ShouldBeTrue(); + w.Tag.ShouldBe("Alarm.A.AckMsg"); + w.Value.ShouldBe("acknowledged by operator"); + } + + [Fact] + public async Task Snapshot_reports_latest_fields() + { + var fake = new FakeSubscriber(); + using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); + await t.TrackAsync("Alarm.A"); + fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); + fake.Subs["Alarm.A.Priority"]("Alarm.A.Priority", Int(900)); + fake.Subs["Alarm.A.DescAttrName"]("Alarm.A.DescAttrName", Str("MyAlarm")); + fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true)); + + var snap = t.SnapshotStates(); + snap.Count.ShouldBe(1); + snap[0].InAlarm.ShouldBeTrue(); + snap[0].Acked.ShouldBeTrue(); + snap[0].Priority.ShouldBe(900); + snap[0].DescAttrName.ShouldBe("MyAlarm"); + } + + [Fact] + public async Task Foreign_probe_callback_is_dropped() + { + var fake = new FakeSubscriber(); + using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); + var transitions = new ConcurrentQueue(); + t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); + + // No TrackAsync was called — this callback is foreign and should be silently ignored. + t.OnProbeCallback("Unknown.InAlarm", Bool(true)); + + transitions.Count.ShouldBe(0); + } +}