From 5d07ac24cbfe6f34254c94ea5682554e7fb75612 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 15:00:20 -0400 Subject: [PATCH] feat(debugview): DV-2 emit placeholder rows for quiet native alarm bindings InstanceActor.BuildAlarmStatesSnapshot now adds an IsConfiguredPlaceholder row per configured native source binding that currently has no live condition, so the Debug View tree can show the binding node even when quiet. A binding is "quiet" when no retained AlarmStateChanged carries its NativeSourceCanonicalName (DV-1). Kind derivation: reuses the exact nativeKind value already computed via ResolveNativeKind(nativeSource.ConnectionName) at the NativeAlarmActor creation site and stored in a new _nativeAlarmKinds dictionary -- the accurate per-binding kind (NativeOpcUa vs NativeMxAccess), not the NativeOpcUa default. Tests: Snapshot_QuietNativeBinding_EmitsPlaceholder, Snapshot_NativeBindingWithLiveCondition_NoPlaceholder. --- .../Actors/InstanceActor.cs | 31 ++++++++++++ .../Actors/InstanceActorNativeAlarmTests.cs | 50 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index d6cea648..9d53897e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -53,6 +53,13 @@ public class InstanceActor : ReceiveActor private readonly Dictionary _alarmActors = new(); private readonly Dictionary _nativeAlarmActors = new(); /// + /// Native alarm kind per source-binding canonical name (the same value handed + /// to each 's _nativeKind). Used to stamp + /// the cosmetic kind badge on placeholder rows for quiet bindings in the + /// DebugView snapshot. + /// + private readonly Dictionary _nativeAlarmKinds = new(); + /// /// Latest enriched per alarm name (computed and /// native), so the DebugView snapshot carries the unified condition + native /// metadata rather than a bare State/Priority projection. @@ -1110,6 +1117,27 @@ public class InstanceActor : ReceiveActor _alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow))); } + // Native source bindings with no live condition: emit a placeholder so the + // Debug View tree shows the configured binding node even when quiet. A binding + // is "quiet" when no retained event carries its canonical name. + var liveBindings = _latestAlarmEvents.Values + .Where(e => !string.IsNullOrEmpty(e.NativeSourceCanonicalName)) + .Select(e => e.NativeSourceCanonicalName) + .ToHashSet(); + + foreach (var binding in _nativeAlarmActors.Keys) + { + if (liveBindings.Contains(binding)) continue; + + states.Add(new AlarmStateChanged( + _instanceUniqueName, binding, AlarmState.Normal, 0, DateTimeOffset.UtcNow) + { + Kind = _nativeAlarmKinds.GetValueOrDefault(binding, AlarmKind.NativeOpcUa), + NativeSourceCanonicalName = binding, + IsConfiguredPlaceholder = true + }); + } + return states; } @@ -1468,6 +1496,9 @@ public class InstanceActor : ReceiveActor var actorRef = Context.ActorOf(props, $"native-alarm-{nativeSource.CanonicalName}"); _nativeAlarmActors[nativeSource.CanonicalName] = actorRef; + // Same kind handed to the NativeAlarmActor — used to stamp the + // (cosmetic) kind badge on placeholder rows for quiet bindings. + _nativeAlarmKinds[nativeSource.CanonicalName] = nativeKind; } _logger.LogInformation( diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs index 8656de72..c76daedc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs @@ -95,6 +95,56 @@ public class InstanceActorNativeAlarmTests : TestKit, IDisposable a.SourceReference == "T01.Hi" && a.Kind == AlarmKind.NativeOpcUa && a.Condition.Severity == 800); } + [Fact] + public void Snapshot_QuietNativeBinding_EmitsPlaceholder() + { + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst"), dcl.Ref); + + // No live condition is emitted: the configured "Pressure" binding is quiet. + actor.Tell(new SubscribeDebugViewRequest("inst", "c")); + var snap = ExpectMsg(); + + var placeholders = snap.AlarmStates + .Where(a => a.NativeSourceCanonicalName == "Pressure" && a.IsConfiguredPlaceholder) + .ToList(); + + Assert.Single(placeholders); + var placeholder = placeholders[0]; + Assert.Equal(AlarmState.Normal, placeholder.State); + Assert.Equal("Pressure", placeholder.NativeSourceCanonicalName); + Assert.Equal(AlarmKind.NativeOpcUa, placeholder.Kind); + } + + [Fact] + public void Snapshot_NativeBindingWithLiveCondition_NoPlaceholder() + { + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("inst", ConfigWithNativeSource("inst"), dcl.Ref); + + // The NativeAlarmActor emits a live condition stamped with the binding's + // canonical name (DV-1: NativeSourceCanonicalName), so the binding is "active". + actor.Tell(new AlarmStateChanged("inst", "Pressure.Hi", AlarmState.Active, 800, DateTimeOffset.UtcNow) + { + Kind = AlarmKind.NativeOpcUa, + SourceReference = "ns=2;s=T01.Hi", + NativeSourceCanonicalName = "Pressure", + Condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800) + }); + + actor.Tell(new SubscribeDebugViewRequest("inst", "c")); + var snap = ExpectMsg(); + + // The live condition is present... + Assert.Contains(snap.AlarmStates, a => + a.SourceReference == "ns=2;s=T01.Hi" && a.NativeSourceCanonicalName == "Pressure" + && a.Kind == AlarmKind.NativeOpcUa && a.Condition.Severity == 800); + + // ...and NO placeholder row is emitted for that binding. + Assert.DoesNotContain(snap.AlarmStates, a => + a.NativeSourceCanonicalName == "Pressure" && a.IsConfiguredPlaceholder); + } + void IDisposable.Dispose() { Shutdown();