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);
+ }
+}