fix(alarms): native-alarm re-statements no longer duplicate history or hide acked alarms (#656)

- AlarmTransitionKind.ActiveAcknowledged (appended): active AND acked applied as ONE projection.
  Galaxy ActiveAcked snapshot entries and OpcUaClient inactive->(active,acked) use it; the
  projector writes 'Activated' only if not already active, 'Acknowledged' only if not acked.
- AlarmEventArgs.IsRestatement (optional, default false): set only by Galaxy snapshot entries
  and OpcUaClient first sight. A flagged Retrigger on an Active+unacked condition with the same
  message and severity, or a flagged Clear on an inactive condition, writes no /alerts row.
- NativeAlarmProjector reports the history rows per transition; ForwardNativeAlarm writes one
  row per entry. Unflagged sources (FOCAS/MTConnect/AbCip, live Galaxy) are unchanged.
- Tests: projector rules each with a must-write control; OpcUaClient transition table;
  Galaxy feed/driver mapping; Galaxy + OpcUaClient end-to-end through DriverHostActor.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 23:35:50 -04:00
parent ca17d2cf71
commit b39bb6794d
15 changed files with 973 additions and 81 deletions
@@ -233,16 +233,16 @@ public class NativeAlarmProjectorTests
public void A_Raise_on_an_already_active_condition_is_a_restatement_that_keeps_the_ack()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var first).Active.ShouldBeTrue();
sut.Project("n1", Evt(AlarmTransitionKind.Raise), out bool first).Active.ShouldBeTrue();
first.ShouldBeFalse("inactive → active is a real transition");
sut.MarkAcknowledged("n1");
var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var restatement);
var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out bool restatement);
restatement.ShouldBeTrue();
restated.Active.ShouldBeTrue();
restated.Acknowledged.ShouldBeTrue("a re-statement must not un-acknowledge");
var retriggered = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out var retrigger);
var retriggered = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out bool retrigger);
retrigger.ShouldBeFalse();
retriggered.Acknowledged.ShouldBeFalse("a Retrigger is a new statement and un-acks");
}
@@ -259,20 +259,209 @@ public class NativeAlarmProjectorTests
sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"));
sut.MarkAcknowledged("n1");
var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out var r1);
var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out bool r1);
r1.ShouldBeTrue();
same.Acknowledged.ShouldBeTrue();
var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out var r2);
var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out bool r2);
r2.ShouldBeFalse("a different alarm is not a re-statement");
other.Acknowledged.ShouldBeFalse("the operator has not seen this alarm");
}
// ---------------------------------------------------------------------------------------------------------
// Gitea #656 — ActiveAcknowledged + IsRestatement. Every "no row" test is paired with a control that MUST write.
// ---------------------------------------------------------------------------------------------------------
/// <summary>#656 claim 1 — an acked active alarm on a condition the host has never seen (a cold start) projects
/// ACTIVE+acked in one update, with an Activated row then an Acknowledged row. Pre-fix Galaxy sent Acknowledge,
/// which projected inactive+acked: an alarm active in AVEVA was invisible to OPC UA clients.</summary>
[Fact]
public void ActiveAcknowledged_on_an_unknown_condition_projects_active_and_acked_with_Activated_then_Acknowledged_rows()
{
var sut = new NativeAlarmProjector();
var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList<AlarmTransitionKind> rows);
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeTrue();
rows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
}
/// <summary>#656 — the wider half of claim 1: a KNOWN but inactive prior (cleared before a gateway outage, then went
/// active and acked during it) must also activate.</summary>
[Fact]
public void ActiveAcknowledged_on_an_inactive_known_prior_activates()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
sut.Project("n1", Evt(AlarmTransitionKind.Clear)).Active.ShouldBeFalse();
var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList<AlarmTransitionKind> rows);
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeTrue();
rows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
}
[Fact]
public void ActiveAcknowledged_on_an_active_unacked_condition_writes_only_an_Acknowledged_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList<AlarmTransitionKind> rows);
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeTrue();
rows.ShouldBe([AlarmTransitionKind.Acknowledge]);
}
/// <summary>#656 related item 2 — a Galaxy reconnect replays an ActiveAcked entry the host already shows; that
/// is no row. Control: the same entry with a different message is a different alarm and writes both rows.</summary>
[Fact]
public void ActiveAcknowledged_on_an_active_acked_condition_with_the_same_message_writes_no_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hi"));
var same = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hi"), out IReadOnlyList<AlarmTransitionKind> sameRows);
sameRows.ShouldBeEmpty();
same.Active.ShouldBeTrue();
same.Acknowledged.ShouldBeTrue();
// Control: a different message is a different alarm.
sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hihi"), out IReadOnlyList<AlarmTransitionKind> otherRows);
otherRows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
}
/// <summary>#656 claim 2 — a re-stated Retrigger (Galaxy snapshot replay, OpcUaClient first sight) on a condition
/// already Active+unacked with the same message and severity writes no row; the node does not change.</summary>
[Fact]
public void A_restated_Retrigger_on_an_active_unacked_condition_with_same_message_and_severity_writes_no_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList<AlarmTransitionKind> first);
first.ShouldBe([AlarmTransitionKind.Retrigger], "the first statement on an unknown condition is a real activation");
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBeEmpty();
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeFalse();
}
/// <summary>Control (a): an UNFLAGGED Retrigger — a live Galaxy re-occurrence, a FOCAS key swap — with the same
/// everything is a genuine re-occurrence and keeps its row. FOCAS/MTConnect/AbCip behaviour is unchanged.</summary>
[Fact]
public void An_unflagged_Retrigger_on_an_active_unacked_condition_still_writes_an_Activated_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger));
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
}
/// <summary>Control (b): a restated Retrigger whose SEVERITY changed (MTConnect Warning→Fault with a detail text
/// keeps the message) is a new statement.</summary>
[Fact]
public void A_restated_Retrigger_with_a_different_severity_writes_an_Activated_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, AlarmSeverity.Medium, restated: true));
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, AlarmSeverity.High, restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
}
/// <summary>Control (b'): the exact-severity path (<see cref="AlarmEventArgs.OpcUaSeverity"/>) is compared too.</summary>
[Fact]
public void A_restated_Retrigger_with_a_different_exact_severity_writes_an_Activated_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true) with { OpcUaSeverity = 500 });
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true) with { OpcUaSeverity = 800 },
out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
}
/// <summary>Control (c): a restated Retrigger with a different message is a different alarm.</summary>
[Fact]
public void A_restated_Retrigger_with_a_different_message_writes_an_Activated_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, msg: "a", restated: true));
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, msg: "b", restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
}
/// <summary>Control (d): a restated Retrigger on an Active+ACKED condition un-acks (upstream's ack state is
/// authoritative), so the node changes and the row is written.</summary>
[Fact]
public void A_restated_Retrigger_on_an_active_acked_condition_unacks_and_writes_an_Activated_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger));
sut.MarkAcknowledged("n1");
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
snap.Acknowledged.ShouldBeFalse();
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
}
/// <summary>#656 related item 3 — a restated Clear (Galaxy Inactive snapshot entry) on a condition that is already
/// inactive writes no row; the unflagged control and a restated Clear of an ACTIVE condition both write one.</summary>
[Fact]
public void A_restated_Clear_on_an_inactive_condition_writes_no_row()
{
var sut = new NativeAlarmProjector();
sut.Project("unknown", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList<AlarmTransitionKind> unknownRows);
unknownRows.ShouldBeEmpty();
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
sut.Project("n1", Evt(AlarmTransitionKind.Clear));
sut.Project("n1", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBeEmpty();
// Controls: unflagged on inactive keeps its row (unchanged behaviour); restated on ACTIVE is a real clear.
sut.Project("n1", Evt(AlarmTransitionKind.Clear), out IReadOnlyList<AlarmTransitionKind> unflagged);
unflagged.ShouldBe([AlarmTransitionKind.Clear]);
sut.Project("n2", Evt(AlarmTransitionKind.Raise));
sut.Project("n2", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList<AlarmTransitionKind> active);
active.ShouldBe([AlarmTransitionKind.Clear]);
}
/// <summary>#656 — the existing per-kind rows for unflagged sources are exactly one row of the event's own kind
/// (what the host wrote before, byte-identical for FOCAS/MTConnect/AbCip).</summary>
[Theory]
[InlineData(AlarmTransitionKind.Raise)]
[InlineData(AlarmTransitionKind.Retrigger)]
[InlineData(AlarmTransitionKind.Acknowledge)]
[InlineData(AlarmTransitionKind.Clear)]
[InlineData(AlarmTransitionKind.Unspecified)]
public void An_unflagged_transition_writes_one_row_of_its_own_kind(AlarmTransitionKind kind)
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(kind), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBe([kind]);
}
private static AlarmEventArgs Evt(
AlarmTransitionKind kind,
AlarmSeverity sev = AlarmSeverity.High,
string msg = "m")
=> new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind);
string msg = "m",
bool restated = false)
=> new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind, IsRestatement: restated);
private sealed class H : IAlarmSubscriptionHandle
{
@@ -0,0 +1,456 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading.Channels;
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit;
using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using LocalizedText = Opc.Ua.LocalizedText;
using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto;
using UaNodeId = Opc.Ua.NodeId;
using Variant = Opc.Ua.Variant;
using VariantCollection = Opc.Ua.VariantCollection;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Gitea #656 — native-alarm RE-STATEMENTS end to end: the REAL Galaxy driver (over the REAL
/// <see cref="GatewayGalaxyAlarmFeed"/> reading a scripted gateway stream) and the REAL OpcUaClient driver raise
/// into the REAL <see cref="DriverHostActor"/>, and the test reads what its <c>ForwardNativeAlarm</c> +
/// <c>NativeAlarmProjector</c> turned them into: the Part 9 condition updates and the <c>/alerts</c> rows.
/// </summary>
/// <remarks>
/// Every "no row" assertion is followed by a transition that MUST write one — an absence check on its own passes
/// against a host that never publishes anything.
/// </remarks>
public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("alarm-restatement-e2e");
private static readonly RevisionHash Rev = RevisionHash.Parse(new string('a', 64));
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(5);
private static readonly TimeSpan Quiet = TimeSpan.FromMilliseconds(600);
// ---------------------------------------------------------------------------------------------------------
// Galaxy
// ---------------------------------------------------------------------------------------------------------
private const string GalaxyDriverId = "drv-galaxy";
private const string GalaxyAlarmRef = "Tank01.Level.HiHi";
private const string GalaxyRawPath = "Plant/Galaxy/gw/tank01_hihi";
/// <summary>
/// #656 claim 1 — a cold host receives an ActiveAcked snapshot entry (an alarm active AND acknowledged in
/// AVEVA before this process started). Pre-fix the feed sent Acknowledge, which the projector applied to its
/// inactive default: the condition was INACTIVE+acked — invisible to clients — with an "Acknowledged" row for
/// an alarm the server showed as inactive. Now: ONE Active+acked update, "Activated" (at the original raise
/// time, no operator comment) then "Acknowledged" (carrying the comment).
/// </summary>
[Fact]
public async Task Galaxy_cold_host_ActiveAcked_snapshot_projects_active_and_acked_with_both_rows()
{
var rig = await GalaxyRig.StartAsync(this);
try
{
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
var ackedAt = raisedAt.AddMinutes(2);
rig.Send(Snapshot(Proto.AlarmConditionState.ActiveAcked, raisedAt, ackedAt, "alice", "investigating"));
var update = NextAlarmUpdate(rig.Publish);
update.AlarmNodeId.ShouldBe(GalaxyRawPath);
update.State.Active.ShouldBeTrue("an acked active alarm must project ACTIVE, not keep the cold default");
update.State.Acknowledged.ShouldBeTrue();
var activated = rig.Alerts.ExpectMsg<AlarmTransitionEvent>(Wait);
activated.TransitionKind.ShouldBe("Activated");
activated.Comment.ShouldBeNull("the operator comment belongs to the acknowledgement");
activated.TimestampUtc.ShouldBe(raisedAt);
var acked = rig.Alerts.ExpectMsg<AlarmTransitionEvent>(Wait);
acked.TransitionKind.ShouldBe("Acknowledged");
acked.Comment.ShouldBe("investigating");
rig.Alerts.ExpectNoMsg(Quiet);
// Positive control: a live Clear MUST write its row — and the NEXT condition update is the clear, so no
// unacked intermediate update sat between the snapshot's projection and it.
rig.Send(Live(Proto.AlarmTransitionKind.Clear, ackedAt.AddMinutes(1)));
var cleared = NextAlarmUpdate(rig.Publish);
cleared.State.Active.ShouldBeFalse();
cleared.State.Acknowledged.ShouldBeTrue();
rig.Alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
}
finally
{
await rig.DisposeAsync();
}
}
/// <summary>
/// #656 claim 2 — every gateway reconnect replays the active-alarm snapshot. Pre-fix an unacked active entry
/// (a Retrigger) wrote a second "Activated" row on EVERY reconnect, for a condition whose node did not change
/// (so clients saw no event: history and the node disagreed). Likewise an Inactive entry wrote a duplicate
/// "Cleared". Now a replay re-projects the condition but writes no row; a live transition still does.
/// </summary>
[Fact]
public async Task Galaxy_reconnect_snapshot_replay_writes_no_duplicate_rows()
{
var rig = await GalaxyRig.StartAsync(this);
try
{
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
rig.Send(Snapshot(Proto.AlarmConditionState.Active, raisedAt, raisedAt));
NextAlarmUpdate(rig.Publish).State.Active.ShouldBeTrue();
rig.Alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated",
"the first statement on a cold host is a real activation");
// The gateway stream faults; the feed reopens and the gateway replays the same snapshot.
rig.Fault();
rig.Send(Snapshot(Proto.AlarmConditionState.Active, raisedAt, raisedAt));
var replayed = NextAlarmUpdate(rig.Publish);
replayed.State.Active.ShouldBeTrue();
replayed.State.Acknowledged.ShouldBeFalse();
rig.Alerts.ExpectNoMsg(Quiet);
// Positive control: a live Clear MUST write its row.
rig.Send(Live(Proto.AlarmTransitionKind.Clear, raisedAt.AddMinutes(1)));
NextAlarmUpdate(rig.Publish).State.Active.ShouldBeFalse();
rig.Alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
// Reconnect again; the snapshot now carries the alarm as Inactive — a re-stated Clear of an inactive
// condition, no row.
rig.Fault();
rig.Send(Snapshot(Proto.AlarmConditionState.Inactive, raisedAt, raisedAt.AddMinutes(1)));
NextAlarmUpdate(rig.Publish).State.Active.ShouldBeFalse();
rig.Alerts.ExpectNoMsg(Quiet);
// Positive control: a LIVE Retrigger (a genuine MXAccess re-occurrence) is never a re-statement.
rig.Send(Live(Proto.AlarmTransitionKind.Retrigger, raisedAt.AddMinutes(3)));
NextAlarmUpdate(rig.Publish).State.Active.ShouldBeTrue();
rig.Alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
}
finally
{
await rig.DisposeAsync();
}
}
private static Proto.AlarmFeedMessage Snapshot(
Proto.AlarmConditionState state, DateTime raisedAt, DateTime lastTransition, string user = "", string comment = "")
=> new()
{
ActiveAlarm = new Proto.ActiveAlarmSnapshot
{
AlarmFullReference = GalaxyAlarmRef,
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
Severity = 750,
CurrentState = state,
Category = "Process",
Description = "Tank 01 high-high level",
OriginalRaiseTimestamp = Timestamp.FromDateTime(raisedAt),
LastTransitionTimestamp = Timestamp.FromDateTime(lastTransition),
OperatorUser = user,
OperatorComment = comment,
},
};
private static Proto.AlarmFeedMessage Live(Proto.AlarmTransitionKind kind, DateTime at)
=> new()
{
Transition = new Proto.OnAlarmTransitionEvent
{
AlarmFullReference = GalaxyAlarmRef,
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = kind,
Severity = 750,
Category = "Process",
Description = "Tank 01 high-high level",
TransitionTimestamp = Timestamp.FromDateTime(at),
},
};
/// <summary>The real Galaxy driver over the real alarm feed, reading a scripted gateway stream.</summary>
private sealed class GalaxyRig : IAsyncDisposable
{
private readonly Channel<object> _stream = Channel.CreateUnbounded<object>();
private GalaxyDriver? _driver;
public TestProbe Publish { get; private set; } = null!;
public TestProbe Alerts { get; private set; } = null!;
public static async Task<GalaxyRig> StartAsync(NativeAlarmRestatementEndToEndTests t)
{
var rig = new GalaxyRig();
var db = NewInMemoryDbFactory();
var blob = BuildArtifact(GalaxyDriverId, DriverTypeNames.Galaxy, "Galaxy", "gw", "tank01_hihi",
new { attributeRef = GalaxyAlarmRef, alarm = new { alarmType = "AlarmCondition", severity = 800 } });
rig.Alerts = t.SubscribeAlerts();
var (host, publish, _) = t.SpawnHostAndApply(db, Seed(db, blob, Rev));
rig.Publish = publish;
var rawTags = RawTagsFor(blob, GalaxyDriverId);
rawTags.Select(r => r.RawPath).ShouldBe([GalaxyRawPath]);
var feed = new GatewayGalaxyAlarmFeed(rig.OpenStream, clientName: "Restatement656",
reconnectDelay: TimeSpan.FromMilliseconds(20));
var options = new GalaxyDriverOptions(
new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"),
new GalaxyMxAccessOptions("Restatement656"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions()) { RawTags = rawTags };
rig._driver = new GalaxyDriver(GalaxyDriverId, options, hierarchySource: null, alarmFeed: feed);
rig._driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(GalaxyDriverId, e));
#pragma warning disable OTOPCUA0001 // the routing is under test, not resilience wrapping
await rig._driver.SubscribeAlarmsAsync([GalaxyRawPath], CancellationToken.None);
#pragma warning restore OTOPCUA0001
return rig;
}
public void Send(Proto.AlarmFeedMessage message) => _stream.Writer.TryWrite(message).ShouldBeTrue();
/// <summary>Fault the current gateway stream; the feed reopens it (the next open reads on).</summary>
public void Fault() => _stream.Writer.TryWrite(new IOException("synthetic gateway stream fault")).ShouldBeTrue();
private async IAsyncEnumerable<Proto.AlarmFeedMessage> OpenStream(
Proto.StreamAlarmsRequest request, [EnumeratorCancellation] CancellationToken ct)
{
while (true)
{
var item = await _stream.Reader.ReadAsync(ct);
if (item is Exception ex) throw ex;
yield return (Proto.AlarmFeedMessage)item;
}
}
public async ValueTask DisposeAsync()
{
if (_driver is not null) await _driver.ShutdownAsync(CancellationToken.None);
_driver?.Dispose();
}
}
// ---------------------------------------------------------------------------------------------------------
// OpcUaClient
// ---------------------------------------------------------------------------------------------------------
private const string OpcDriverId = "drv-opcua";
private const string OpcRawPath = "Plant/Opc/plc/trip1";
private const string UpstreamSource = "ns=3;s=Alarms.Trip1";
/// <summary>
/// #656 claim 2 (OpcUaClient) — a deploy that changes the driver's config RESPAWNS it, and the fresh instance's
/// ConditionRefresh re-reads every retained condition with no record of what the host was told. Its first
/// sight of an unacked active alarm is a Retrigger (#647: upstream's unack is authoritative); pre-fix that
/// wrote a second "Activated" row for a condition already showing exactly that. Now it is a flagged
/// re-statement: the condition is re-projected (still unacked) and no row is written.
/// </summary>
[Fact]
public async Task OpcUaClient_respawn_restating_an_unacked_active_alarm_writes_no_second_Activated_row()
{
var db = NewInMemoryDbFactory();
var blob = BuildOpcUaArtifact();
var alerts = SubscribeAlerts();
var (host, publish, coordinator) = SpawnHostAndApply(db, Seed(db, blob, Rev));
var handle = new Handle();
using (var a = NewOpcUaDriver(host, "a"))
{
a.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle);
var raised = NextAlarmUpdate(publish);
raised.AlarmNodeId.ShouldBe(OpcRawPath);
raised.State.Active.ShouldBeTrue();
raised.State.Acknowledged.ShouldBeFalse();
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
}
// A config-changing deploy: the host keeps the still-deployed condition's state, the driver is respawned.
var revB = RevisionHash.Parse(new string('b', 64));
host.Tell(new DispatchDeployment(Seed(db, blob, revB), revB, CorrelationId.NewId()));
coordinator.FishForMessage<ApplyAck>(x => x.Outcome == ApplyAckOutcome.Applied, Wait);
publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, Wait);
using var b = NewOpcUaDriver(host, "b");
b.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle); // ConditionRefresh copy
var restated = NextAlarmUpdate(publish);
restated.State.Active.ShouldBeTrue();
restated.State.Acknowledged.ShouldBeFalse("upstream says unacknowledged");
alerts.ExpectNoMsg(Quiet);
// Positive control: the upstream operator acknowledges — that transition MUST write its row.
b.RouteAlarmEvent(ConditionFields(active: true, acked: true), SourceIndex, handle);
var acked = NextAlarmUpdate(publish);
acked.State.Acknowledged.ShouldBeTrue();
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Acknowledged");
}
/// <summary>
/// #656 related item 1 (OpcUaClient) — an upstream condition first seen Active+acked used to go out as
/// Raise-then-Acknowledge: TWO condition updates, the first Active+UNacked (a client-visible un-ack window).
/// Now it is ONE ActiveAcknowledged: the first condition update is already acked, with "Activated" then
/// "Acknowledged" rows.
/// </summary>
[Fact]
public void OpcUaClient_first_sight_of_an_acked_active_alarm_is_one_active_and_acked_update()
{
var db = NewInMemoryDbFactory();
var alerts = SubscribeAlerts();
var (host, publish, _) = SpawnHostAndApply(db, Seed(db, BuildOpcUaArtifact(), Rev));
var handle = new Handle();
using var drv = NewOpcUaDriver(host, "cold");
drv.RouteAlarmEvent(ConditionFields(active: true, acked: true), SourceIndex, handle);
var first = NextAlarmUpdate(publish);
first.State.Active.ShouldBeTrue();
first.State.Acknowledged.ShouldBeTrue("no Active+unacked intermediate may reach clients");
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Acknowledged");
alerts.ExpectNoMsg(Quiet);
// Positive control: the next update is the clear (not a leftover second update), and it writes its row.
drv.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle);
var cleared = NextAlarmUpdate(publish);
cleared.State.Active.ShouldBeFalse();
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
}
private static readonly Dictionary<string, string> SourceIndex = new() { [UpstreamSource] = OpcRawPath };
private static OpcUaClientDriver NewOpcUaDriver(IActorRef host, string suffix)
{
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), $"{OpcDriverId}-{suffix}");
drv.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(OpcDriverId, e));
return drv;
}
/// <summary>An upstream A&amp;C condition event, in the driver's select-clause order.</summary>
private static VariantCollection ConditionFields(bool active, bool acked) =>
[
new Variant(new byte[] { 1 }), // EventId
new Variant(new UaNodeId("TripAlarmType", 3)), // EventType
new Variant(UaNodeId.Parse(UpstreamSource)), // SourceNode
new Variant(new LocalizedText("tripped")), // Message
new Variant((ushort)700), // Severity
new Variant(DateTime.UtcNow), // Time
new Variant(UaNodeId.Parse(UpstreamSource + ".Cond")), // ConditionId
new Variant(active), // ActiveState/Id
new Variant(acked), // AckedState/Id
Variant.Null, // BranchId
];
private static byte[] BuildOpcUaArtifact() => BuildArtifact(OpcDriverId, DriverTypeNames.OpcUaClient, "Opc", "plc", "trip1",
new { nodeId = UpstreamSource, alarm = new { alarmType = "AlarmCondition", severity = 700 } });
private sealed class Handle : IAlarmSubscriptionHandle
{
public string DiagnosticId => "e2e";
}
// ---------------------------------------------------------------------------------------------------------
// Shared host harness (as FocasNativeAlarmEndToEndTests)
// ---------------------------------------------------------------------------------------------------------
/// <summary>The next condition update the host sent to the publish actor (skipping anything else).</summary>
private static OpcUaPublishActor.AlarmStateUpdate NextAlarmUpdate(TestProbe publish) =>
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, Wait);
private TestProbe SubscribeAlerts()
{
var alerts = CreateTestProbe();
DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, alerts.Ref), alerts.Ref);
alerts.ExpectMsg<SubscribeAck>(Wait);
return alerts;
}
private static List<RawTagEntry> RawTagsFor(byte[] blob, string driverInstanceId)
{
using var artifact = DeploymentArtifact.Open(blob);
var spec = artifact.ParseDriverInstances().Single(s => s.DriverInstanceId == driverInstanceId);
using var doc = JsonDocument.Parse(spec.DriverConfig);
return JsonSerializer.Deserialize<List<RawTagEntry>>(
doc.RootElement.GetProperty("RawTags").GetRawText(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
private static byte[] BuildArtifact(
string driverId, string driverType, string driverName, string deviceName, string tagName, object tagConfig) =>
JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
// Enabled=false: the host must not spawn a real wire driver — the test drives its own instance.
new { DriverInstanceId = driverId, RawFolderId = "rf-plant", Name = driverName, DriverType = driverType, DriverConfig = "{}", ClusterId = "c1", Enabled = false },
},
Devices = new[]
{
new { DeviceId = "dev-1", DriverInstanceId = driverId, Name = deviceName, DeviceConfig = "{}" },
},
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new
{
TagId = "tag-alarm",
DeviceId = "dev-1",
TagGroupId = (string?)null,
Name = tagName,
DataType = "Boolean",
AccessLevel = 0,
TagConfig = JsonSerializer.Serialize(tagConfig),
},
},
});
private static DeploymentId Seed(IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] blob, RevisionHash rev)
{
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = rev.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = blob,
});
ctx.SaveChanges();
return id;
}
private (IActorRef Actor, TestProbe Publish, TestProbe Coordinator) SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
var coordinator = CreateTestProbe();
var publish = this.CreateCleanOutcomePublishProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
dependencyMux: CreateTestProbe().Ref,
opcUaPublishActor: publish.Ref,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: CreateTestProbe().Ref));
actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Wait).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Wait);
return (actor, publish, coordinator);
}
}
@@ -34,6 +34,10 @@
<!-- Gitea #647: the real FOCAS driver, so the native-alarm end-to-end test proves a CNC alarm reaches the
host's Part 9 condition (ForwardNativeAlarm → NativeAlarmProjector), not merely that it was raised. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
<!-- Gitea #656: the real Galaxy (over a scripted gateway alarm stream) and OpcUaClient drivers, so the
re-statement end-to-end tests prove what reaches the host's /alerts rows and Part 9 conditions. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
</ItemGroup>
<ItemGroup>