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:
Joseph Doherty
2026-10-08 15:38:52 -04:00
parent 6a5f5c5a28
commit 9c8e3da5da
7 changed files with 382 additions and 112 deletions
@@ -3,10 +3,18 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// What the server was last TOLD about one condition (#626): the last alarm statement it was given
/// (active or Normal — never Unavailable) and whether its Quality is currently annotated Bad.
/// </summary>
/// <param name="Statement">The last statement delivered; <c>null</c> when only an Unavailable was ever seen.</param>
/// <param name="QualityBad">Whether the condition's Quality was last annotated <c>BadNoCommunication</c>.</param>
internal sealed record MTConnectToldCondition(MTConnectConditionState? Statement, bool QualityBad);
/// <summary> /// <summary>
/// The MTConnect CONDITION → OPC UA Part 9 mapping (#626), as one pure function: given what the server was /// The MTConnect CONDITION → OPC UA Part 9 mapping (#626), as one pure function: given what the server was
/// last told about a condition and what the Agent says now, the single <see cref="AlarmEventArgs"/> to /// last told about a condition and what the Agent says now, the single <see cref="AlarmEventArgs"/> to
/// raise — or none. /// raise — or none — and what the server has then been told.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <list type="table"> /// <list type="table">
@@ -14,22 +22,31 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <item><term>Normal</term><description>inactive (return to normal), quality Good</description></item> /// <item><term>Normal</term><description>inactive (return to normal), quality Good</description></item>
/// <item><term>Warning</term><description>active, severity 500 (<see cref="AlarmSeverity.Medium"/>)</description></item> /// <item><term>Warning</term><description>active, severity 500 (<see cref="AlarmSeverity.Medium"/>)</description></item>
/// <item><term>Fault</term><description>active, severity 800 (<see cref="AlarmSeverity.High"/> bucket)</description></item> /// <item><term>Fault</term><description>active, severity 800 (<see cref="AlarmSeverity.High"/> bucket)</description></item>
/// <item><term>Unavailable</term><description>inactive, quality <c>BadNoCommunication</c></description></item> /// <item><term>Unavailable</term><description>state UNCHANGED, quality <c>BadNoCommunication</c></description></item>
/// </list> /// </list>
/// <para> /// <para>
/// Inactive → active is a <see cref="AlarmTransitionKind.Raise"/>; active → a DIFFERENT active statement /// <b>Unavailable is comms loss one hop further out, not a return to normal.</b> A cppagent marks every
/// (Warning → Fault, a new <c>nativeCode</c>, a changed message) is a /// DataItem of an adapter UNAVAILABLE when the adapter socket drops, so it is treated exactly like the
/// <see cref="AlarmTransitionKind.Retrigger"/> — active and unacknowledged again with the new severity /// host's own transport loss (#477/#481): an active condition STAYS active (and keeps its ack), only its
/// and message; active → Normal/Unavailable is a <see cref="AlarmTransitionKind.Clear"/>. A change that /// Quality goes Bad, as a quality-only annotation (<see cref="AlarmEventArgs.IsQualityOnly"/>, no
/// moves only the quality (Normal ⇄ Unavailable) is a quality-only annotation /// <c>/alerts</c> row). When data returns: the same statement ⇒ quality-only Good; Normal ⇒ Clear; a
/// (<see cref="AlarmEventArgs.IsQualityOnly"/>). An unchanged statement /// different active statement ⇒ Retrigger.
/// (<see cref="MTConnectConditionState.SameAlarmAs"/>) emits nothing — which is what keeps a reconnect's
/// replayed <c>/current</c> from re-raising, and so un-acknowledging, an alarm the operator acked.
/// </para> /// </para>
/// <para> /// <para>
/// The one deliberate exception is a <c>resync</c>: an unchanged <c>Unavailable</c> is /// Otherwise: inactive → active is a <see cref="AlarmTransitionKind.Raise"/>; active → a DIFFERENT active
/// re-asserted on a (re)subscribe, because the host re-annotates every condition Good when the driver /// statement (Warning → Fault, a new winning <c>nativeCode</c>) is a
/// reconnects (#477) and nothing else would put the per-condition Bad back. /// <see cref="AlarmTransitionKind.Retrigger"/> — active and unacknowledged again with the new severity
/// and message (so clearing the worst of several codes re-raises the next one down, un-acked, by
/// design: it is a statement the operator has not seen); active → Normal is a
/// <see cref="AlarmTransitionKind.Clear"/>. An unchanged statement
/// (<see cref="MTConnectConditionState.SameAlarmAs"/> — text excluded) emits nothing, which keeps a
/// reconnect's replayed <c>/current</c> from re-raising, and so un-acknowledging, an acked alarm; a
/// text-only change likewise updates nothing, so the message shown stays the statement's first text.
/// </para>
/// <para>
/// <b>Resync.</b> On a (re)subscribe an unchanged Bad quality is re-asserted, because the host
/// re-annotates every condition Good when the driver reconnects (#477) and nothing else would put the
/// per-condition Bad back.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal static class MTConnectConditionAlarmMapper internal static class MTConnectConditionAlarmMapper
@@ -47,18 +64,18 @@ internal static class MTConnectConditionAlarmMapper
private const uint BadNoCommunication = 0x80310000u; private const uint BadNoCommunication = 0x80310000u;
/// <summary> /// <summary>
/// The event moving the server from <paramref name="delivered"/> to <paramref name="current"/>, or /// The event moving the server from <paramref name="told"/> to <paramref name="current"/> (or <c>null</c>
/// <c>null</c> when nothing needs to be said. /// when nothing needs to be said), and the new told record.
/// </summary> /// </summary>
/// <param name="delivered">What the server was last told for this condition; <c>null</c> when nothing yet.</param> /// <param name="told">What the server was last told for this condition; <c>null</c> when nothing yet.</param>
/// <param name="current">The Agent's current statement.</param> /// <param name="current">The Agent's current statement.</param>
/// <param name="conditionId">The condition's ConditionId — the alarm-bearing raw tag's RawPath.</param> /// <param name="conditionId">The condition's ConditionId — the alarm-bearing raw tag's RawPath.</param>
/// <param name="dataItemId">The Agent DataItem id (carried as <see cref="AlarmEventArgs.SourceNodeId"/>).</param> /// <param name="dataItemId">The Agent DataItem id (carried as <see cref="AlarmEventArgs.SourceNodeId"/>).</param>
/// <param name="handle">The alarm subscription the event belongs to.</param> /// <param name="handle">The alarm subscription the event belongs to.</param>
/// <param name="resync">True on a (re)subscribe: re-assert an unchanged Unavailable quality.</param> /// <param name="resync">True on a (re)subscribe: re-assert an unchanged Bad quality.</param>
/// <returns>The event to raise, or <c>null</c>.</returns> /// <returns>The event to raise (or <c>null</c>) and what the server has been told afterwards.</returns>
public static AlarmEventArgs? Transition( public static (AlarmEventArgs? Event, MTConnectToldCondition Told) Transition(
MTConnectConditionState? delivered, MTConnectToldCondition? told,
MTConnectConditionState current, MTConnectConditionState current,
string conditionId, string conditionId,
string dataItemId, string dataItemId,
@@ -67,34 +84,47 @@ internal static class MTConnectConditionAlarmMapper
{ {
ArgumentNullException.ThrowIfNull(current); ArgumentNullException.ThrowIfNull(current);
var wasActive = delivered?.IsActive == true; var statement = told?.Statement;
var bad = told?.QualityBad ?? false;
if (delivered is not null && delivered.SameAlarmAs(current)) if (current.Level == MTConnectConditionLevel.Unavailable)
{ {
return resync && current.Level == MTConnectConditionLevel.Unavailable // State untouched; only the quality moves (or is re-asserted on a resync).
? Build(current, delivered, conditionId, dataItemId, handle, AlarmTransitionKind.Unspecified) var next = new MTConnectToldCondition(statement, QualityBad: true);
: null; return !bad || resync
? (QualityOnly(statement ?? current, current, conditionId, dataItemId, handle, BadNoCommunication), next)
: (null, next);
} }
var told2 = new MTConnectToldCondition(current, QualityBad: false);
if (statement is not null && statement.SameAlarmAs(current))
{
// Unchanged statement: keep the FIRST text the server was shown. Only a Bad quality is lifted.
var kept = told2 with { Statement = statement };
return bad
? (QualityOnly(statement, current, conditionId, dataItemId, handle, Good), kept)
: (null, kept);
}
var wasActive = statement?.IsActive == true;
if (current.IsActive) if (current.IsActive)
{ {
return Build(current, delivered, conditionId, dataItemId, handle, return (Build(current, statement, conditionId, dataItemId, handle,
wasActive ? AlarmTransitionKind.Retrigger : AlarmTransitionKind.Raise); wasActive ? AlarmTransitionKind.Retrigger : AlarmTransitionKind.Raise), told2);
} }
if (wasActive) if (wasActive)
{ {
return Build(current, delivered, conditionId, dataItemId, handle, AlarmTransitionKind.Clear); return (Build(current, statement, conditionId, dataItemId, handle, AlarmTransitionKind.Clear), told2);
} }
// Inactive → inactive with a different level: only the quality moved. Nothing to say for a first-seen // Normal after nothing / after Normal: nothing to say about the state — the host's connectivity
// Normal — the host's connectivity annotation already confirms a materialised condition Good. // annotation confirms a materialised condition Good — except lifting a Bad this driver put there.
if (delivered is null && current.Level == MTConnectConditionLevel.Normal) return bad
{ ? (QualityOnly(current, current, conditionId, dataItemId, handle, Good), told2)
return null; : (null, told2);
}
return Build(current, delivered, conditionId, dataItemId, handle, AlarmTransitionKind.Unspecified);
} }
/// <summary>The Part 9 severity of an active level.</summary> /// <summary>The Part 9 severity of an active level.</summary>
@@ -124,19 +154,46 @@ internal static class MTConnectConditionAlarmMapper
return builder.ToString(); return builder.ToString();
} }
/// <summary>
/// A quality-only annotation. The host writes ONLY the condition's Quality for it (never message,
/// severity or ack state), so the message/severity carried are informational: those of the statement the
/// server holds.
/// </summary>
private static AlarmEventArgs QualityOnly(
MTConnectConditionState shown,
MTConnectConditionState current,
string conditionId,
string dataItemId,
IAlarmSubscriptionHandle handle,
uint quality)
{
var severity = shown.IsActive ? SeverityOf(shown.Level) : WarningSeverity;
return new AlarmEventArgs(
SubscriptionHandle: handle,
SourceNodeId: dataItemId,
ConditionId: conditionId,
AlarmType: shown.Detail.ConditionType ?? "CONDITION",
Message: MessageOf(shown),
Severity: severity >= FaultSeverity ? AlarmSeverity.High : AlarmSeverity.Medium,
SourceTimestampUtc: current.TimestampUtc,
Kind: AlarmTransitionKind.Unspecified,
OpcUaSeverity: severity,
QualityStatusCode: quality);
}
private static AlarmEventArgs Build( private static AlarmEventArgs Build(
MTConnectConditionState current, MTConnectConditionState current,
MTConnectConditionState? delivered, MTConnectConditionState? previous,
string conditionId, string conditionId,
string dataItemId, string dataItemId,
IAlarmSubscriptionHandle handle, IAlarmSubscriptionHandle handle,
AlarmTransitionKind kind) AlarmTransitionKind kind)
{ {
// An inactive statement keeps the severity of the alarm it clears (or the one last shown), so a clear // A clear keeps the severity of the alarm it clears, so it does not visibly re-grade the condition.
// does not visibly re-grade the condition; a never-active condition reports Warning's 500.
var severity = current.IsActive var severity = current.IsActive
? SeverityOf(current.Level) ? SeverityOf(current.Level)
: delivered?.IsActive == true ? SeverityOf(delivered.Level) : WarningSeverity; : previous?.IsActive == true ? SeverityOf(previous.Level) : WarningSeverity;
return new AlarmEventArgs( return new AlarmEventArgs(
SubscriptionHandle: handle, SubscriptionHandle: handle,
@@ -148,6 +205,7 @@ internal static class MTConnectConditionAlarmMapper
SourceTimestampUtc: current.TimestampUtc, SourceTimestampUtc: current.TimestampUtc,
Kind: kind, Kind: kind,
OpcUaSeverity: severity, OpcUaSeverity: severity,
QualityStatusCode: current.Level == MTConnectConditionLevel.Unavailable ? BadNoCommunication : Good); // A real transition only ever happens on live data, so it also lifts any Bad annotation.
QualityStatusCode: Good);
} }
} }
@@ -38,11 +38,13 @@ internal sealed record MTConnectConditionState(
/// <summary> /// <summary>
/// Whether two states are the SAME alarm statement — the dedup key the alarm path diffs on. Two active /// Whether two states are the SAME alarm statement — the dedup key the alarm path diffs on. Two active
/// states match only when level, <c>nativeCode</c>, <c>nativeSeverity</c>, <c>qualifier</c> and message /// states match when level, <c>nativeCode</c>, <c>nativeSeverity</c> and <c>qualifier</c> agree (a new
/// text all agree (a new code, an escalation, or a changed message is a new statement); two inactive /// code or an escalation is a new statement); two inactive states match on the level alone, so a
/// states match on the level alone, so a re-reported Normal never re-clears. The timestamp and the /// re-reported Normal never re-clears. NOT part of it: the timestamp and the active count (a replayed
/// active count are deliberately NOT part of it: a replayed <c>/current</c> after a reconnect carries the /// <c>/current</c> after a reconnect carries the same condition under a new sequence, and re-raising it
/// same condition under a new sequence, and re-raising it would un-acknowledge an operator's ack. /// would un-acknowledge an operator's ack) and the message TEXT (an adapter that embeds a live value in
/// the text, e.g. "Spindle temp 82C", would otherwise re-raise and un-ack on every tick — the message
/// shown stays the statement's first text).
/// </summary> /// </summary>
/// <param name="other">The state to compare against.</param> /// <param name="other">The state to compare against.</param>
/// <returns>True when the alarm path must emit nothing to move from one to the other.</returns> /// <returns>True when the alarm path must emit nothing to move from one to the other.</returns>
@@ -53,8 +55,7 @@ internal sealed record MTConnectConditionState(
return string.Equals(Detail.NativeCode, other.Detail.NativeCode, StringComparison.Ordinal) return string.Equals(Detail.NativeCode, other.Detail.NativeCode, StringComparison.Ordinal)
&& string.Equals(Detail.NativeSeverity, other.Detail.NativeSeverity, StringComparison.Ordinal) && string.Equals(Detail.NativeSeverity, other.Detail.NativeSeverity, StringComparison.Ordinal)
&& string.Equals(Detail.Qualifier, other.Detail.Qualifier, StringComparison.Ordinal) && string.Equals(Detail.Qualifier, other.Detail.Qualifier, StringComparison.Ordinal);
&& string.Equals(Detail.Text, other.Detail.Text, StringComparison.Ordinal);
} }
} }
@@ -75,7 +76,8 @@ internal sealed record MTConnectConditionState(
/// <b>Rules, per DataItem, applied in document order.</b> <c>Warning</c>/<c>Fault</c> activates (or /// <b>Rules, per DataItem, applied in document order.</b> <c>Warning</c>/<c>Fault</c> activates (or
/// replaces) the entry keyed by its <c>nativeCode</c> (blank = one unnamed entry). <c>Normal</c> with a /// replaces) the entry keyed by its <c>nativeCode</c> (blank = one unnamed entry). <c>Normal</c> with a
/// <c>nativeCode</c> clears that code; <c>Normal</c> without one clears every code. <c>Unavailable</c> /// <c>nativeCode</c> clears that code; <c>Normal</c> without one clears every code. <c>Unavailable</c>
/// clears every code and marks the item unavailable until any other level arrives. A <b>baseline</b> /// clears every code and marks the item unavailable until any other level arrives (the alarm path does
/// NOT treat that as a clear — see <see cref="MTConnectConditionAlarmMapper"/>). A <b>baseline</b>
/// (<c>/current</c>, which lists every currently-active condition) first resets each DataItem it /// (<c>/current</c>, which lists every currently-active condition) first resets each DataItem it
/// mentions, so an entry cleared while the driver was not listening cannot survive a re-baseline. An /// mentions, so an entry cleared while the driver was not listening cannot survive a re-baseline. An
/// unrecognised (vendor) state word is treated as an active <c>Warning</c> — surfacing an unknown /// unrecognised (vendor) state word is treated as an active <c>Warning</c> — surfacing an unknown
@@ -92,7 +94,6 @@ internal sealed class MTConnectConditionTracker
private readonly Lock _lock = new(); private readonly Lock _lock = new();
private readonly Dictionary<string, ItemState> _items = new(StringComparer.Ordinal); private readonly Dictionary<string, ItemState> _items = new(StringComparer.Ordinal);
private long _order;
/// <summary> /// <summary>
/// Folds every CONDITION observation of <paramref name="document"/> into the tracked state. /// Folds every CONDITION observation of <paramref name="document"/> into the tracked state.
@@ -144,11 +145,13 @@ internal sealed class MTConnectConditionTracker
if (item.Active.Count > 0) if (item.Active.Count > 0)
{ {
// Most severe wins; among equals, the most recently activated — the newest statement the // Most severe wins; among equals, a DETERMINISTIC tie-break (ordinal nativeCode, then type) —
// operator has not yet seen. // never document order, which an Agent may present differently on a re-baseline and which would
// then re-raise (and un-ack) an unchanged condition on every reconnect.
var winner = item.Active.Values var winner = item.Active.Values
.OrderByDescending(e => e.Level) .OrderByDescending(e => e.Level)
.ThenByDescending(e => e.Order) .ThenBy(e => e.Detail.NativeCode ?? string.Empty, StringComparer.Ordinal)
.ThenBy(e => e.Detail.ConditionType ?? string.Empty, StringComparer.Ordinal)
.First(); .First();
return new MTConnectConditionState(winner.Level, winner.Detail, winner.TimestampUtc, item.Active.Count); return new MTConnectConditionState(winner.Level, winner.Detail, winner.TimestampUtc, item.Active.Count);
@@ -185,7 +188,7 @@ internal sealed class MTConnectConditionTracker
private void ApplyOne(ItemState item, MTConnectObservation observation) private void ApplyOne(ItemState item, MTConnectObservation observation)
{ {
var detail = observation.Condition!; var detail = observation.Condition!;
var entry = new Entry(LevelOf(observation.Value), detail, observation.TimestampUtc, ++_order); var entry = new Entry(LevelOf(observation.Value), detail, observation.TimestampUtc);
switch (entry.Level) switch (entry.Level)
{ {
@@ -209,7 +212,7 @@ internal sealed class MTConnectConditionTracker
} }
} }
private sealed record Entry(MTConnectConditionLevel Level, MTConnectConditionDetail Detail, DateTime TimestampUtc, long Order); private sealed record Entry(MTConnectConditionLevel Level, MTConnectConditionDetail Detail, DateTime TimestampUtc);
private sealed class ItemState private sealed class ItemState
{ {
@@ -48,11 +48,19 @@ public sealed partial class MTConnectDriver : IAlarmSource
private readonly Lock _alarmLock = new(); private readonly Lock _alarmLock = new();
/// <summary> /// <summary>
/// ConditionId (RawPath) → the state the server was last told. Guarded by <see cref="_alarmLock"/>. /// ConditionId (RawPath) → what the server was last told. Guarded by <see cref="_alarmLock"/>. Survives a
/// Survives re-subscribes and lifecycle restarts on purpose — it describes the SERVER's condition node, /// re-subscribe with the SAME reference set (a reconnect) and lifecycle restarts — the server's condition
/// which outlives both. /// nodes outlive both, and re-raising there would un-acknowledge. Cleared WHOLE when the subscribed set
/// changes: only an alarm-bearing raw-tag delta changes it, that delta is a full address-space rebuild,
/// and a rebuild recreates every condition node inactive — so everything must be told again.
/// </summary> /// </summary>
private readonly Dictionary<string, MTConnectConditionState> _deliveredConditions = new(StringComparer.Ordinal); private readonly Dictionary<string, MTConnectToldCondition> _deliveredConditions = new(StringComparer.Ordinal);
/// <summary>
/// The reference set of the last <see cref="SubscribeAlarmsAsync"/>, kept across an unsubscribe so a
/// release-then-resubscribe of an identical set is still recognised. Guarded by <see cref="_alarmLock"/>.
/// </summary>
private HashSet<string>? _lastAlarmReferences;
/// <summary>The CONDITION tracker for the current session; replaced alongside the observation index.</summary> /// <summary>The CONDITION tracker for the current session; replaced alongside the observation index.</summary>
private MTConnectConditionTracker _conditions = new(); private MTConnectConditionTracker _conditions = new();
@@ -100,12 +108,16 @@ public sealed partial class MTConnectDriver : IAlarmSource
lock (_alarmLock) lock (_alarmLock)
{ {
// A condition that left the set has its node torn down by the server; if it comes back it is a NEW // A CHANGED set means an alarm-bearing delta, which the server applies as a full rebuild: every
// node that has been told nothing, so forget what its predecessor was told. // condition node — including ones still in the set — was recreated inactive and has been told
foreach (var stale in _deliveredConditions.Keys.Where(k => !references.Contains(k)).ToList()) // nothing. Re-tell all of them. The SAME set is a reconnect: keep the record, or every still-active
// alarm would be re-raised and lose its acknowledgement.
if (_lastAlarmReferences is null || !_lastAlarmReferences.SetEquals(references))
{ {
_deliveredConditions.Remove(stale); _deliveredConditions.Clear();
} }
_lastAlarmReferences = references;
} }
EmitConditionTransitions(touchedDataItemIds: null, resync: true); EmitConditionTransitions(touchedDataItemIds: null, resync: true);
@@ -192,10 +204,10 @@ public sealed partial class MTConnectDriver : IAlarmSource
var current = tracker.Get(dataItemId); var current = tracker.Get(dataItemId);
if (current is null) continue; if (current is null) continue;
_deliveredConditions.TryGetValue(conditionId, out var delivered); _deliveredConditions.TryGetValue(conditionId, out var told);
var evt = MTConnectConditionAlarmMapper.Transition( var (evt, nowTold) = MTConnectConditionAlarmMapper.Transition(
delivered, current, conditionId, dataItemId, subscription.Handle, resync); told, current, conditionId, dataItemId, subscription.Handle, resync);
_deliveredConditions[conditionId] = current; _deliveredConditions[conditionId] = nowTold;
if (evt is null) continue; if (evt is null) continue;
@@ -1789,6 +1789,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_localNode, msg.DriverInstanceId, msg.Args.ConditionId); _localNode, msg.DriverInstanceId, msg.Args.ConditionId);
return; return;
} }
// #626 — a QUALITY-ONLY annotation (an MTConnect condition gone Unavailable, or back) is not an alarm
// transition. It rides the SAME quality-only node path the #477 connectivity annotation uses
// (AlarmQualityUpdate → WriteAlarmQuality, which touches Quality and nothing else), never a projected
// snapshot: the projector's prior state is cleared on every apply, so a projected "keep the state"
// after a deploy would default to inactive+acked and silently acknowledge a retained, unacked
// condition — and would rewrite its message and severity. Ungated by role (a Secondary keeps quality
// warm, as for connectivity) and no alerts row.
if (msg.Args is { IsQualityOnly: true, QualityStatusCode: { } quality })
{
foreach (var n in nodeIds)
{
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmQualityUpdate(
n.NodeId, quality, msg.Args.SourceTimestampUtc, n.Realm));
}
return;
}
// PRIMARY GATE (archreview 03/S4) — decided ONCE for this transition: the OPC UA condition write // PRIMARY GATE (archreview 03/S4) — decided ONCE for this transition: the OPC UA condition write
// below stays UNGATED (a Secondary keeps its address space warm for failover), but only the Primary // below stays UNGATED (a Secondary keeps its address space warm for failover), but only the Primary
// publishes the single cluster-wide alerts copy. Denied on a Secondary/Detached, OR on a multi-driver // publishes the single cluster-wide alerts copy. Denied on a Secondary/Detached, OR on a multi-driver
@@ -1811,11 +1830,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate( _opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm)); nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
// Only the Primary publishes the cluster-wide alerts transition (see the gate above). A quality-only // Only the Primary publishes the cluster-wide alerts transition (see the gate above).
// annotation (#626: e.g. an MTConnect condition gone Unavailable while already Normal) is not an if (!serviceAlertsAsPrimary) continue;
// alarm transition at all — the condition write above carries it; an alerts row would read as
// "Activated" for something that never activated.
if (!serviceAlertsAsPrimary || msg.Args.IsQualityOnly) continue;
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m) var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null, ? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null,
@@ -20,6 +20,7 @@ public sealed class MTConnectAlarmSourceTests
private const string ConditionRawPath = "Plant/MT/dev1/SystemCond"; private const string ConditionRawPath = "Plant/MT/dev1/SystemCond";
private const string ConditionValueRawPath = "Plant/MT/dev1/SystemCondText"; private const string ConditionValueRawPath = "Plant/MT/dev1/SystemCondText";
private const string ConditionId = "dev1_system_cond"; private const string ConditionId = "dev1_system_cond";
private const string OtherConditionRawPath = "Plant/MT/dev1/OtherCond";
private const long FixtureInstanceId = 1655000000L; private const long FixtureInstanceId = 1655000000L;
private const long RestartedInstanceId = 1655999999L; private const long RestartedInstanceId = 1655999999L;
@@ -39,6 +40,7 @@ public sealed class MTConnectAlarmSourceTests
// must not disturb the existing value surface. // must not disturb the existing value surface.
new RawTagEntry(ConditionRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"), new RawTagEntry(ConditionRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"),
new RawTagEntry(ConditionValueRawPath, $$"""{"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); 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] [Fact]
public async Task A_resubscribe_with_an_unchanged_active_state_raises_nothing() public async Task A_resubscribe_with_an_unchanged_active_state_raises_nothing()
{ {
@@ -113,6 +113,21 @@ public sealed class MTConnectConditionAlarmMappingTests
MTConnectConditionTracker.LevelOf("fault").ShouldBe(MTConnectConditionLevel.Fault); 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 ---- // ---- mapper: state mapping ----
[Fact] [Fact]
@@ -154,15 +169,14 @@ public sealed class MTConnectConditionAlarmMappingTests
[Fact] [Fact]
public void Normal_then_fault_then_normal_is_raise_then_clear() public void Normal_then_fault_then_normal_is_raise_then_clear()
{ {
var normal = State(MTConnectConditionLevel.Normal); var events = Sequence(
var fault = State(MTConnectConditionLevel.Fault, "overtemp", "E17"); 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"); events[0].ShouldBeNull("a first-seen Normal has nothing to say — the node starts inactive");
events[1].ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Raise);
var raise = Map(normal, fault).ShouldNotBeNull(); var clear = events[2].ShouldNotBeNull();
raise.Kind.ShouldBe(AlarmTransitionKind.Raise);
var clear = Map(fault, normal).ShouldNotBeNull();
clear.Kind.ShouldBe(AlarmTransitionKind.Clear); clear.Kind.ShouldBe(AlarmTransitionKind.Clear);
clear.QualityStatusCode.ShouldBe(Good); clear.QualityStatusCode.ShouldBe(Good);
clear.OpcUaSeverity.ShouldBe(800, "a clear keeps the severity of the alarm it clears"); clear.OpcUaSeverity.ShouldBe(800, "a clear keeps the severity of the alarm it clears");
@@ -171,11 +185,11 @@ public sealed class MTConnectConditionAlarmMappingTests
[Fact] [Fact]
public void Warning_escalating_to_fault_is_a_retrigger_at_the_new_severity() public void Warning_escalating_to_fault_is_a_retrigger_at_the_new_severity()
{ {
var warning = State(MTConnectConditionLevel.Warning, "coolant low", "W1"); var events = Sequence(
var fault = State(MTConnectConditionLevel.Fault, "coolant empty", "F1"); State(MTConnectConditionLevel.Warning, "coolant low", "W1"),
State(MTConnectConditionLevel.Fault, "coolant empty", "F1"));
var evt = Map(warning, fault).ShouldNotBeNull();
var evt = events[1].ShouldNotBeNull();
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger); evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
evt.OpcUaSeverity.ShouldBe(800); evt.OpcUaSeverity.ShouldBe(800);
evt.Message.ShouldStartWith("coolant empty"); evt.Message.ShouldStartWith("coolant empty");
@@ -184,10 +198,9 @@ public sealed class MTConnectConditionAlarmMappingTests
[Fact] [Fact]
public void A_different_active_native_code_at_the_same_level_is_a_retrigger() 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")) // Includes the documented "clearing the worst code re-raises the next one down, un-acked" case.
.ShouldNotBeNull(); Sequence(State(MTConnectConditionLevel.Fault, "a", "A"), State(MTConnectConditionLevel.Fault, "b", "B"))[1]
.ShouldNotBeNull().Kind.ShouldBe(AlarmTransitionKind.Retrigger);
evt.Kind.ShouldBe(AlarmTransitionKind.Retrigger);
} }
[Fact] [Fact]
@@ -202,64 +215,140 @@ public sealed class MTConnectConditionAlarmMappingTests
Map(before, replayed, resync: true).ShouldBeNull(); 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] [Fact]
public void A_repeated_normal_with_different_text_does_not_re_clear() public void A_repeated_normal_with_different_text_does_not_re_clear()
{ {
Map(State(MTConnectConditionLevel.Normal, "ok"), State(MTConnectConditionLevel.Normal, "still ok")).ShouldBeNull(); Map(State(MTConnectConditionLevel.Normal, "ok"), State(MTConnectConditionLevel.Normal, "still ok")).ShouldBeNull();
} }
// ---- mapper: Unavailable (comms loss one hop out) ----
[Fact] [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)) var evt = Map(State(MTConnectConditionLevel.Fault, "x", "E1"), State(MTConnectConditionLevel.Unavailable))
.ShouldNotBeNull(); .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.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] [Fact]
public void An_inactive_condition_going_unavailable_and_back_is_quality_only() public void An_inactive_condition_going_unavailable_and_back_is_quality_only()
{ {
var normal = State(MTConnectConditionLevel.Normal); var events = Sequence(
var unavailable = State(MTConnectConditionLevel.Unavailable); State(MTConnectConditionLevel.Normal),
State(MTConnectConditionLevel.Unavailable),
State(MTConnectConditionLevel.Normal));
var lost = Map(normal, unavailable).ShouldNotBeNull(); events[1].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
lost.IsQualityOnly.ShouldBeTrue(); events[1]!.QualityStatusCode.ShouldBe(BadNoCommunication);
lost.QualityStatusCode.ShouldBe(BadNoCommunication); events[2].ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
events[2]!.QualityStatusCode.ShouldBe(Good);
var back = Map(unavailable, normal).ShouldNotBeNull(); Map(null, State(MTConnectConditionLevel.Unavailable)).ShouldNotBeNull().IsQualityOnly
back.IsQualityOnly.ShouldBeTrue(); .ShouldBeTrue("first-seen Unavailable must reach the node");
back.QualityStatusCode.ShouldBe(Good);
Map(null, unavailable).ShouldNotBeNull().IsQualityOnly.ShouldBeTrue("first-seen Unavailable must reach the node");
} }
[Fact] [Fact]
public void An_unchanged_unavailable_is_reasserted_only_on_a_resync() 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. // 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); var unavailable = State(MTConnectConditionLevel.Unavailable);
Map(unavailable, unavailable).ShouldBeNull(); MTConnectConditionAlarmMapper.Transition(told, unavailable, RawPath, Id, Handle, resync: false).Event.ShouldBeNull();
Map(unavailable, unavailable, resync: true).ShouldNotBeNull().IsQualityOnly.ShouldBeTrue(); MTConnectConditionAlarmMapper.Transition(told, unavailable, RawPath, Id, Handle, resync: true).Event
.ShouldNotBeNull().IsQualityOnly.ShouldBeTrue();
} }
[Fact] [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")) var events = Sequence(State(MTConnectConditionLevel.Unavailable), State(MTConnectConditionLevel.Fault, "x", "E1"));
.ShouldNotBeNull();
var evt = events[1].ShouldNotBeNull();
evt.Kind.ShouldBe(AlarmTransitionKind.Raise); evt.Kind.ShouldBe(AlarmTransitionKind.Raise);
evt.QualityStatusCode.ShouldBe(Good); evt.QualityStatusCode.ShouldBe(Good);
} }
// ---- helpers ---- // ---- helpers ----
private static AlarmEventArgs? Map(MTConnectConditionState? delivered, MTConnectConditionState current, bool resync = false) => /// <summary>Maps one step from a told state of <paramref name="statement"/> (Good quality).</summary>
MTConnectConditionAlarmMapper.Transition(delivered, current, RawPath, Id, Handle, resync); 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( private static MTConnectConditionState State(
MTConnectConditionLevel level, MTConnectConditionLevel level,
@@ -273,7 +273,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
/// <summary>Gitea #626 — a QUALITY-ONLY native event (an <see cref="AlarmTransitionKind.Unspecified"/> kind /// <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 /// 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 /// <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> /// MUST publish, and it must be the FIRST alerts message, so the absence is proven rather than timed.</summary>
[Fact] [Fact]
@@ -298,10 +299,10 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
Kind: AlarmTransitionKind.Unspecified, Kind: AlarmTransitionKind.Unspecified,
QualityStatusCode: OpcUaStatus.BadNoCommunication))); 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.AlarmNodeId.ShouldBe(AlarmRawPath);
annotated.State.Active.ShouldBeFalse(); annotated.Realm.ShouldBe(AddressSpaceRealm.Raw);
annotated.State.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); annotated.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
// Positive control: a real transition after it DOES publish, and is the first thing on the topic. // 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( actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
@@ -327,6 +328,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
evt.Severity.ShouldBe(800); 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 /// <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 /// 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 /// lost → Bad, restored → Good, at the raw condition NodeId + realm. No alarm transition is involved — this