feat(streaming): carry IsMesReportable on AlarmStateUpdate (25) and DebugAlarmStateDto (20) (ScadaBridge#60)

Central's Debug View live stream and Alarm Summary cache always saw false
because neither wire message had the field. Additive proto fields, vendored
C# regenerated with docker/regen-proto.sh (sitestream + sitecommand), mapped
both ways in StreamRelayActor/SiteStreamGrpcClient and SiteCommandDtoMapper.
The live stream publishes Alarm Actor events, which never carried the flag,
so InstanceActor.HandleAlarmStateChanged now stamps it before retaining and
publishing. A pre-#60 site sends the proto3 default. No UI badge yet.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 12:31:37 -05:00
parent 6bd1fd3072
commit 5310fd0e26
14 changed files with 489 additions and 294 deletions
@@ -209,6 +209,40 @@ public class StreamRelayActorTests : TestKit
SiteStreamGrpcClient.ConvertToDomainEvent(unackedProto)).AckTime);
}
/// <summary>
/// Verifies that the MES-reportable flag survives the relay → proto → central decode
/// round trip (Gitea #60, <c>AlarmStateUpdate</c> field 25), and that an unflagged alarm
/// arrives as false.
/// </summary>
[Fact]
public void RelaysAlarmStateChanged_IsMesReportable_SurvivesFullRoundTrip()
{
var channel = Channel.CreateUnbounded<SiteStreamEvent>();
var actor = Sys.ActorOf(Props.Create(() =>
new StreamRelayActor("corr-mes", channel.Writer)));
var flagged = new AlarmStateChanged(
"Site1.Oven1", "HighTemp", AlarmState.Active, 700,
new DateTimeOffset(2026, 11, 9, 12, 0, 0, TimeSpan.Zero))
{
IsMesReportable = true
};
actor.Tell(flagged);
var flaggedProto = ReadProtoEvent(channel);
Assert.True(flaggedProto.AlarmChanged.IsMesReportable);
Assert.True(
Assert.IsType<AlarmStateChanged>(
SiteStreamGrpcClient.ConvertToDomainEvent(flaggedProto)).IsMesReportable);
actor.Tell(flagged with { IsMesReportable = false });
var plainProto = ReadProtoEvent(channel);
Assert.False(plainProto.AlarmChanged.IsMesReportable);
Assert.False(
Assert.IsType<AlarmStateChanged>(
SiteStreamGrpcClient.ConvertToDomainEvent(plainProto)).IsMesReportable);
}
/// <summary>
/// Reads the next relayed proto event, retrying once after a short pause because the
/// relay actor writes to the channel asynchronously (mirrors the existing round-trip
@@ -275,12 +275,16 @@ internal static class SiteCommandSamples
private static AttributeValueChanged AttrValueList() =>
new("Site1.Pump1", "Pump1.Trend", "Trend", new List<object?> { 1, 2.5d, "x", null }, "Good", T1);
/// <summary>A computed alarm: <c>Condition</c> left implicit, which is the common case.</summary>
/// <summary>
/// A computed alarm: <c>Condition</c> left implicit, which is the common case. Flagged
/// MES-reportable so the golden pins <c>DebugAlarmStateDto</c> field 20 (Gitea #60).
/// </summary>
private static AlarmStateChanged ComputedAlarm() =>
new("Site1.Pump1", "HighSpeed", AlarmState.Active, 700, T1)
{
Level = AlarmLevel.HighHigh,
Message = "Speed critically high"
Message = "Speed critically high",
IsMesReportable = true
};
/// <summary>A mirrored native alarm: every native enrichment field populated, <c>Condition</c> explicit.</summary>
@@ -8,16 +8,18 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// The deployed alarm's <c>IsMesReportable</c> flag must reach the site-local alarm
/// snapshot that <c>Alarms.CurrentAsync()</c> and the Debug View query both build (the
/// flag does not cross <c>DebugAlarmStateDto</c> to central in this slice). It is stamped in ONE
/// place — <c>BuildAlarmStatesSnapshot</c> — so a computed alarm that has fired (served
/// snapshot that <c>Alarms.CurrentAsync()</c> and the Debug View query both build, and the
/// events published to the site stream (Gitea #60). The snapshot projection stamps every
/// row in <c>BuildAlarmStatesSnapshot</c>, so a computed alarm that has fired (served
/// from the retained <c>AlarmStateChanged</c>) and one that never has (served from the
/// bare Normal fallback) can never disagree about the flag.
/// </summary>
@@ -154,6 +156,41 @@ public class InstanceActorMesReportableSnapshotTests : TestKit, IDisposable
Assert.False(Assert.Single(snapshot.Alarms, a => a.AlarmName == "TempSpike").IsMesReportable);
}
/// <summary>
/// Verifies that the event the Instance Actor publishes to the site stream — the one the
/// Debug View live feed and central's Alarm Summary cache consume — carries the deployed
/// MES-reportable flag, not only the snapshot (Gitea #60).
/// </summary>
[Fact]
public void PublishedAlarmEvent_CarriesIsMesReportable()
{
const string instance = "Oven3";
var streamManager = new SiteStreamManager(_options, NullLogger<SiteStreamManager>.Instance);
streamManager.Initialize(Sys);
var subscriber = CreateTestProbe();
streamManager.Subscribe(instance, subscriber.Ref);
var actor = ActorOf(Props.Create(() => new InstanceActor(
instance,
JsonSerializer.Serialize(TwoComputedAlarmsConfig(instance)),
_storage,
_compilationService,
_sharedScriptLibrary,
streamManager,
_options,
NullLogger<InstanceActor>.Instance,
null)));
actor.Tell(new SetStaticAttributeCommand(
"corr-temp", instance, "Temperature", "90", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5)).Success);
var published = subscriber.FishForMessage<AlarmStateChanged>(
m => m.AlarmName == "HighTemp" && m.State == AlarmState.Active,
TimeSpan.FromSeconds(5));
Assert.True(published.IsMesReportable);
}
/// <inheritdoc />
void IDisposable.Dispose()
{