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();