fix(mtconnect): review rulings for CONDITION alarms (#626)
- Unavailable keeps an ACTIVE condition active: quality-only BadNoCommunication; on data return same statement => quality-only Good, Normal => Clear, different => Retrigger. - A changed alarm reference set (alarm-bearing delta = full rebuild) clears the whole last-told record so every active condition is re-asserted; the same set keeps it. - Quality-only events go through the host's AlarmQualityUpdate path, never a projected snapshot (which could ack a retained unacked condition or rewrite message/severity). - Deterministic tie-break between equal-level conditions (ordinal nativeCode, then type). - Dedup key is level+nativeCode+nativeSeverity+qualifier; a text-only change updates nothing. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -20,6 +20,7 @@ public sealed class MTConnectAlarmSourceTests
|
||||
private const string ConditionRawPath = "Plant/MT/dev1/SystemCond";
|
||||
private const string ConditionValueRawPath = "Plant/MT/dev1/SystemCondText";
|
||||
private const string ConditionId = "dev1_system_cond";
|
||||
private const string OtherConditionRawPath = "Plant/MT/dev1/OtherCond";
|
||||
private const long FixtureInstanceId = 1655000000L;
|
||||
private const long RestartedInstanceId = 1655999999L;
|
||||
|
||||
@@ -39,6 +40,7 @@ public sealed class MTConnectAlarmSourceTests
|
||||
// must not disturb the existing value surface.
|
||||
new RawTagEntry(ConditionRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"),
|
||||
new RawTagEntry(ConditionValueRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"),
|
||||
new RawTagEntry(OtherConditionRawPath, """{"address":"dev1_other_cond"}""", false, "dev1"),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -125,6 +127,55 @@ public sealed class MTConnectAlarmSourceTests
|
||||
await driver.ShutdownAsync(Ct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_ack_survives_an_adapter_unavailable_blip()
|
||||
{
|
||||
// A cppagent marks an adapter's items UNAVAILABLE when the adapter socket drops. The Fault must stay
|
||||
// active (no Clear/Raise pair, which would reset the operator's ack); only its quality moves.
|
||||
var (driver, agent, events) = await AlarmDriverAsync();
|
||||
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||
|
||||
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17", "overtemp"))).WaitAsync(Watchdog, Ct);
|
||||
await agent.PumpAsync(Chunk(109, 110, Cond("UNAVAILABLE"))).WaitAsync(Watchdog, Ct);
|
||||
await agent.PumpAsync(Chunk(110, 111, Cond("Fault", "E17", "overtemp"))).WaitAsync(Watchdog, Ct);
|
||||
|
||||
var seen = events.ToArray();
|
||||
seen.Select(e => e.Kind).ShouldBe(
|
||||
[AlarmTransitionKind.Raise, AlarmTransitionKind.Unspecified, AlarmTransitionKind.Unspecified]);
|
||||
seen[1].IsQualityOnly.ShouldBeTrue();
|
||||
seen[1].QualityStatusCode.ShouldBe(0x80310000u);
|
||||
seen[2].IsQualityOnly.ShouldBeTrue();
|
||||
seen[2].QualityStatusCode.ShouldBe(0x00000000u);
|
||||
|
||||
await driver.ShutdownAsync(Ct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_changed_alarm_set_re_tells_every_condition_and_the_same_set_does_not()
|
||||
{
|
||||
// Review MAJOR 2: a changed set is an alarm-bearing delta ⇒ full rebuild ⇒ every condition node was
|
||||
// recreated inactive, so an active condition still in the set must be re-asserted onto it.
|
||||
var (driver, agent, events) = await AlarmDriverAsync();
|
||||
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
||||
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
|
||||
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct);
|
||||
events.Count.ShouldBe(2, "A is re-raised onto its rebuilt node");
|
||||
events.Last().ConditionId.ShouldBe(ConditionRawPath);
|
||||
events.Last().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct);
|
||||
|
||||
// Positive control: the next real transition is the very next event.
|
||||
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
||||
events.Select(e => e.Kind).ShouldBe(
|
||||
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear],
|
||||
"the same set (a reconnect) must re-raise nothing");
|
||||
|
||||
await driver.ShutdownAsync(Ct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_resubscribe_with_an_unchanged_active_state_raises_nothing()
|
||||
{
|
||||
|
||||
+125
-36
@@ -113,6 +113,21 @@ public sealed class MTConnectConditionAlarmMappingTests
|
||||
MTConnectConditionTracker.LevelOf("fault").ShouldBe(MTConnectConditionLevel.Fault);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Equal_level_conditions_tie_break_deterministically_regardless_of_document_order()
|
||||
{
|
||||
// Review MINOR 2: an Agent that lists the same two Warnings in a different order on a re-baseline must
|
||||
// not change the winner, or every reconnect would retrigger (and un-ack) the condition.
|
||||
var forward = new MTConnectConditionTracker();
|
||||
forward.Apply(Doc(Cond("Warning", "B", at: 0), Cond("Warning", "A", at: 1)), baseline: true);
|
||||
var reverse = new MTConnectConditionTracker();
|
||||
reverse.Apply(Doc(Cond("Warning", "A", at: 0), Cond("Warning", "B", at: 1)), baseline: true);
|
||||
|
||||
forward.Get(Id).ShouldNotBeNull().Detail.NativeCode.ShouldBe("A");
|
||||
reverse.Get(Id).ShouldNotBeNull().Detail.NativeCode.ShouldBe("A");
|
||||
}
|
||||
|
||||
// ---- mapper: state mapping ----
|
||||
|
||||
[Fact]
|
||||
@@ -154,15 +169,14 @@ public sealed class MTConnectConditionAlarmMappingTests
|
||||
[Fact]
|
||||
public void Normal_then_fault_then_normal_is_raise_then_clear()
|
||||
{
|
||||
var normal = State(MTConnectConditionLevel.Normal);
|
||||
var fault = State(MTConnectConditionLevel.Fault, "overtemp", "E17");
|
||||
var events = Sequence(
|
||||
State(MTConnectConditionLevel.Normal),
|
||||
State(MTConnectConditionLevel.Fault, "overtemp", "E17"),
|
||||
State(MTConnectConditionLevel.Normal));
|
||||
|
||||
Map(null, normal).ShouldBeNull("a first-seen Normal has nothing to say — the node starts inactive");
|
||||
|
||||
var raise = Map(normal, fault).ShouldNotBeNull();
|
||||
raise.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
|
||||
var clear = Map(fault, normal).ShouldNotBeNull();
|
||||
events[0].ShouldBeNull("a first-seen Normal has nothing to say — the node starts inactive");
|
||||
events[1].ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
var clear = events[2].ShouldNotBeNull();
|
||||
clear.Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||
clear.QualityStatusCode.ShouldBe(Good);
|
||||
clear.OpcUaSeverity.ShouldBe(800, "a clear keeps the severity of the alarm it clears");
|
||||
@@ -171,11 +185,11 @@ public sealed class MTConnectConditionAlarmMappingTests
|
||||
[Fact]
|
||||
public void Warning_escalating_to_fault_is_a_retrigger_at_the_new_severity()
|
||||
{
|
||||
var warning = State(MTConnectConditionLevel.Warning, "coolant low", "W1");
|
||||
var fault = State(MTConnectConditionLevel.Fault, "coolant empty", "F1");
|
||||
|
||||
var evt = Map(warning, fault).ShouldNotBeNull();
|
||||
var events = Sequence(
|
||||
State(MTConnectConditionLevel.Warning, "coolant low", "W1"),
|
||||
State(MTConnectConditionLevel.Fault, "coolant empty", "F1"));
|
||||
|
||||
var evt = events[1].ShouldNotBeNull();
|
||||
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
|
||||
evt.OpcUaSeverity.ShouldBe(800);
|
||||
evt.Message.ShouldStartWith("coolant empty");
|
||||
@@ -184,10 +198,9 @@ public sealed class MTConnectConditionAlarmMappingTests
|
||||
[Fact]
|
||||
public void A_different_active_native_code_at_the_same_level_is_a_retrigger()
|
||||
{
|
||||
var evt = Map(State(MTConnectConditionLevel.Fault, "a", "A"), State(MTConnectConditionLevel.Fault, "b", "B"))
|
||||
.ShouldNotBeNull();
|
||||
|
||||
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
|
||||
// Includes the documented "clearing the worst code re-raises the next one down, un-acked" case.
|
||||
Sequence(State(MTConnectConditionLevel.Fault, "a", "A"), State(MTConnectConditionLevel.Fault, "b", "B"))[1]
|
||||
.ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Retrigger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -202,64 +215,140 @@ public sealed class MTConnectConditionAlarmMappingTests
|
||||
Map(before, replayed, resync: true).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_text_only_change_updates_nothing_and_the_first_text_is_kept()
|
||||
{
|
||||
// Review MINOR 4 / ruling 5: an adapter embedding a live value in the text must not re-raise (un-ack).
|
||||
var first = State(MTConnectConditionLevel.Fault, "Spindle temp 82C", "E17");
|
||||
var (evt, told) = MTConnectConditionAlarmMapper.Transition(
|
||||
new MTConnectToldCondition(first, false), State(MTConnectConditionLevel.Fault, "Spindle temp 83C", "E17"),
|
||||
RawPath, Id, Handle, resync: false);
|
||||
|
||||
evt.ShouldBeNull();
|
||||
told.Statement.ShouldNotBeNull().Detail.Text.ShouldBe("Spindle temp 82C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_repeated_normal_with_different_text_does_not_re_clear()
|
||||
{
|
||||
Map(State(MTConnectConditionLevel.Normal, "ok"), State(MTConnectConditionLevel.Normal, "still ok")).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- mapper: Unavailable (comms loss one hop out) ----
|
||||
|
||||
[Fact]
|
||||
public void An_active_condition_going_unavailable_clears_with_bad_quality()
|
||||
public void An_active_condition_going_unavailable_stays_active_with_a_bad_quality_annotation()
|
||||
{
|
||||
var evt = Map(State(MTConnectConditionLevel.Fault, "x", "E1"), State(MTConnectConditionLevel.Unavailable))
|
||||
.ShouldNotBeNull();
|
||||
|
||||
evt.Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||
evt.IsQualityOnly.ShouldBeTrue("not a Clear: the state is unknown, not normal (#477/#481 posture)");
|
||||
evt.QualityStatusCode.ShouldBe(BadNoCommunication);
|
||||
evt.Message.ShouldBe("SYSTEM UNAVAILABLE");
|
||||
evt.Message.ShouldBe("x [nativeCode=E1]", "the annotation describes the statement the server still holds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ack_survives_an_unavailable_blip_when_the_same_fault_returns()
|
||||
{
|
||||
// Fault → UNAVAILABLE → the same Fault: two quality-only annotations, no Clear, no Raise — so the
|
||||
// operator's acknowledgement is never reset.
|
||||
var events = Sequence(
|
||||
State(MTConnectConditionLevel.Fault, "x", "E1"),
|
||||
State(MTConnectConditionLevel.Unavailable),
|
||||
State(MTConnectConditionLevel.Fault, "x", "E1"));
|
||||
|
||||
events[0].ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
events[1].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
|
||||
events[1]!.QualityStatusCode.ShouldBe(BadNoCommunication);
|
||||
events[2].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
|
||||
events[2]!.QualityStatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_active_condition_returning_normal_from_unavailable_is_a_clear()
|
||||
{
|
||||
var events = Sequence(
|
||||
State(MTConnectConditionLevel.Fault, "x", "E1"),
|
||||
State(MTConnectConditionLevel.Unavailable),
|
||||
State(MTConnectConditionLevel.Normal));
|
||||
|
||||
var clear = events[2].ShouldNotBeNull();
|
||||
clear.Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||
clear.QualityStatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_active_condition_returning_with_a_different_statement_is_a_retrigger()
|
||||
{
|
||||
var events = Sequence(
|
||||
State(MTConnectConditionLevel.Warning, "w", "W1"),
|
||||
State(MTConnectConditionLevel.Unavailable),
|
||||
State(MTConnectConditionLevel.Fault, "f", "F1"));
|
||||
|
||||
var evt = events[2].ShouldNotBeNull();
|
||||
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
|
||||
evt.QualityStatusCode.ShouldBe(Good);
|
||||
evt.OpcUaSeverity.ShouldBe(800);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_inactive_condition_going_unavailable_and_back_is_quality_only()
|
||||
{
|
||||
var normal = State(MTConnectConditionLevel.Normal);
|
||||
var unavailable = State(MTConnectConditionLevel.Unavailable);
|
||||
var events = Sequence(
|
||||
State(MTConnectConditionLevel.Normal),
|
||||
State(MTConnectConditionLevel.Unavailable),
|
||||
State(MTConnectConditionLevel.Normal));
|
||||
|
||||
var lost = Map(normal, unavailable).ShouldNotBeNull();
|
||||
lost.IsQualityOnly.ShouldBeTrue();
|
||||
lost.QualityStatusCode.ShouldBe(BadNoCommunication);
|
||||
events[1].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
|
||||
events[1]!.QualityStatusCode.ShouldBe(BadNoCommunication);
|
||||
events[2].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
|
||||
events[2]!.QualityStatusCode.ShouldBe(Good);
|
||||
|
||||
var back = Map(unavailable, normal).ShouldNotBeNull();
|
||||
back.IsQualityOnly.ShouldBeTrue();
|
||||
back.QualityStatusCode.ShouldBe(Good);
|
||||
|
||||
Map(null, unavailable).ShouldNotBeNull().IsQualityOnly.ShouldBeTrue("first-seen Unavailable must reach the node");
|
||||
Map(null, State(MTConnectConditionLevel.Unavailable)).ShouldNotBeNull().IsQualityOnly
|
||||
.ShouldBeTrue("first-seen Unavailable must reach the node");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_unavailable_is_reasserted_only_on_a_resync()
|
||||
{
|
||||
// The host re-annotates every condition Good on reconnect (#477); only the resync puts the Bad back.
|
||||
var told = new MTConnectToldCondition(State(MTConnectConditionLevel.Fault, "x", "E1"), QualityBad: true);
|
||||
var unavailable = State(MTConnectConditionLevel.Unavailable);
|
||||
|
||||
Map(unavailable, unavailable).ShouldBeNull();
|
||||
Map(unavailable, unavailable, resync: true).ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
|
||||
MTConnectConditionAlarmMapper.Transition(told, unavailable, RawPath, Id, Handle, resync: false).Event.ShouldBeNull();
|
||||
MTConnectConditionAlarmMapper.Transition(told, unavailable, RawPath, Id, Handle, resync: true).Event
|
||||
.ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unavailable_recovering_straight_to_fault_raises_with_good_quality()
|
||||
public void A_first_seen_unavailable_recovering_straight_to_fault_raises_with_good_quality()
|
||||
{
|
||||
var evt = Map(State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Fault, "x", "E1"))
|
||||
.ShouldNotBeNull();
|
||||
var events = Sequence(State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Fault, "x", "E1"));
|
||||
|
||||
var evt = events[1].ShouldNotBeNull();
|
||||
evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
evt.QualityStatusCode.ShouldBe(Good);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static AlarmEventArgs? Map(MTConnectConditionState? delivered, MTConnectConditionState current, bool resync = false) =>
|
||||
MTConnectConditionAlarmMapper.Transition(delivered, current, RawPath, Id, Handle, resync);
|
||||
/// <summary>Maps one step from a told state of <paramref name="statement"/> (Good quality).</summary>
|
||||
private static AlarmEventArgs? Map(MTConnectConditionState? statement, MTConnectConditionState current, bool resync = false) =>
|
||||
MTConnectConditionAlarmMapper.Transition(
|
||||
statement is null ? null : new MTConnectToldCondition(statement, false), current, RawPath, Id, Handle, resync).Event;
|
||||
|
||||
/// <summary>Threads the told record through a sequence of Agent statements, returning each step's event.</summary>
|
||||
private static AlarmEventArgs?[] Sequence(params MTConnectConditionState[] states)
|
||||
{
|
||||
MTConnectToldCondition? told = null;
|
||||
var events = new AlarmEventArgs?[states.Length];
|
||||
for (var i = 0; i < states.Length; i++)
|
||||
{
|
||||
(events[i], told) = MTConnectConditionAlarmMapper.Transition(told, states[i], RawPath, Id, Handle, resync: false);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static MTConnectConditionState State(
|
||||
MTConnectConditionLevel level,
|
||||
|
||||
+45
-4
@@ -273,7 +273,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
|
||||
/// <summary>Gitea #626 — a QUALITY-ONLY native event (an <see cref="AlarmTransitionKind.Unspecified"/> kind
|
||||
/// carrying <see cref="AlarmEventArgs.QualityStatusCode"/>, e.g. an MTConnect condition gone Unavailable while
|
||||
/// already Normal) re-annotates the condition's Quality through the ordinary state update, and publishes NO
|
||||
/// already Normal) re-annotates the condition's Quality through the quality-only node path
|
||||
/// (<see cref="OpcUaPublishActor.AlarmQualityUpdate"/> — NOT a projected snapshot), and publishes NO
|
||||
/// <c>alerts</c> row — it is not an alarm transition. The positive control is the Raise that follows: it
|
||||
/// MUST publish, and it must be the FIRST alerts message, so the absence is proven rather than timed.</summary>
|
||||
[Fact]
|
||||
@@ -298,10 +299,10 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
Kind: AlarmTransitionKind.Unspecified,
|
||||
QualityStatusCode: OpcUaStatus.BadNoCommunication)));
|
||||
|
||||
var annotated = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||
var annotated = publish.ExpectMsg<OpcUaPublishActor.AlarmQualityUpdate>(TimeSpan.FromSeconds(5));
|
||||
annotated.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
annotated.State.Active.ShouldBeFalse();
|
||||
annotated.State.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
annotated.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
annotated.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
|
||||
// Positive control: a real transition after it DOES publish, and is the first thing on the topic.
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
@@ -327,6 +328,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
evt.Severity.ShouldBe(800);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #626 (review MINOR 1) — after a deploy (which clears the projector's prior state), a
|
||||
/// quality-only event on a raised, UNACKNOWLEDGED condition must not reach the node as a snapshot: a projected
|
||||
/// "keep the state" would default to inactive+acked, silently acknowledging it and rewriting its message and
|
||||
/// severity. Only an <see cref="OpcUaPublishActor.AlarmQualityUpdate"/> (which writes Quality alone) may be
|
||||
/// sent. Proven by order: the next message after it is the positive control's state update.</summary>
|
||||
[Fact]
|
||||
public void Quality_only_event_never_sends_a_state_snapshot_that_could_ack_or_rewrite()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "spindle fault", AlarmSeverity.High, Ts,
|
||||
Kind: AlarmTransitionKind.Raise, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good)));
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse();
|
||||
|
||||
// Whatever the projector's prior state is (any apply clears it), a quality-only event must never be
|
||||
// projected — the routing below is the whole guard, so no deploy needs staging here.
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "spindle fault", AlarmSeverity.High, Ts,
|
||||
Kind: AlarmTransitionKind.Unspecified, OpcUaSeverity: 800,
|
||||
QualityStatusCode: OpcUaStatus.BadNoCommunication)));
|
||||
// Positive control.
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(), "Temp", AlarmRawPath, "SYSTEM", "control", AlarmSeverity.High, Ts,
|
||||
Kind: AlarmTransitionKind.Retrigger, OpcUaSeverity: 800, QualityStatusCode: OpcUaStatus.Good)));
|
||||
|
||||
var next = publish.FishForMessage(
|
||||
m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate,
|
||||
TimeSpan.FromSeconds(5));
|
||||
next.ShouldBeOfType<OpcUaPublishActor.AlarmQualityUpdate>().StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
var control = publish.FishForMessage(
|
||||
m => m is OpcUaPublishActor.AlarmQualityUpdate or OpcUaPublishActor.AlarmStateUpdate,
|
||||
TimeSpan.FromSeconds(5));
|
||||
control.ShouldBeOfType<OpcUaPublishActor.AlarmStateUpdate>().State.Message.ShouldBe("control",
|
||||
"the quality-only event must produce no state snapshot ahead of the control");
|
||||
}
|
||||
|
||||
/// <summary>#477 Layer 2 — a driver <see cref="DriverInstanceActor.ConnectivityChanged"/> annotates the
|
||||
/// Quality of the driver's native conditions via <see cref="OpcUaPublishActor.AlarmQualityUpdate"/>: comms
|
||||
/// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this
|
||||
|
||||
Reference in New Issue
Block a user