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:
@@ -3,10 +3,18 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
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>
|
||||
/// 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
|
||||
/// raise — or none.
|
||||
/// raise — or none — and what the server has then been told.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <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>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>Unavailable</term><description>inactive, quality <c>BadNoCommunication</c></description></item>
|
||||
/// <item><term>Unavailable</term><description>state UNCHANGED, quality <c>BadNoCommunication</c></description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Inactive → active is a <see cref="AlarmTransitionKind.Raise"/>; active → a DIFFERENT active statement
|
||||
/// (Warning → Fault, a new <c>nativeCode</c>, a changed message) is a
|
||||
/// <see cref="AlarmTransitionKind.Retrigger"/> — active and unacknowledged again with the new severity
|
||||
/// and message; active → Normal/Unavailable is a <see cref="AlarmTransitionKind.Clear"/>. A change that
|
||||
/// moves only the quality (Normal ⇄ Unavailable) is a quality-only annotation
|
||||
/// (<see cref="AlarmEventArgs.IsQualityOnly"/>). An unchanged statement
|
||||
/// (<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.
|
||||
/// <b>Unavailable is comms loss one hop further out, not a return to normal.</b> A cppagent marks every
|
||||
/// DataItem of an adapter UNAVAILABLE when the adapter socket drops, so it is treated exactly like the
|
||||
/// host's own transport loss (#477/#481): an active condition STAYS active (and keeps its ack), only its
|
||||
/// Quality goes Bad, as a quality-only annotation (<see cref="AlarmEventArgs.IsQualityOnly"/>, no
|
||||
/// <c>/alerts</c> row). When data returns: the same statement ⇒ quality-only Good; Normal ⇒ Clear; a
|
||||
/// different active statement ⇒ Retrigger.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The one deliberate exception is a <c>resync</c>: an unchanged <c>Unavailable</c> is
|
||||
/// re-asserted on a (re)subscribe, because the host re-annotates every condition Good when the driver
|
||||
/// reconnects (#477) and nothing else would put the per-condition Bad back.
|
||||
/// Otherwise: inactive → active is a <see cref="AlarmTransitionKind.Raise"/>; active → a DIFFERENT active
|
||||
/// statement (Warning → Fault, a new winning <c>nativeCode</c>) is a
|
||||
/// <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>
|
||||
/// </remarks>
|
||||
internal static class MTConnectConditionAlarmMapper
|
||||
@@ -47,18 +64,18 @@ internal static class MTConnectConditionAlarmMapper
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
/// <summary>
|
||||
/// The event moving the server from <paramref name="delivered"/> to <paramref name="current"/>, or
|
||||
/// <c>null</c> when nothing needs to be said.
|
||||
/// The event moving the server from <paramref name="told"/> to <paramref name="current"/> (or <c>null</c>
|
||||
/// when nothing needs to be said), and the new told record.
|
||||
/// </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="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="handle">The alarm subscription the event belongs to.</param>
|
||||
/// <param name="resync">True on a (re)subscribe: re-assert an unchanged Unavailable quality.</param>
|
||||
/// <returns>The event to raise, or <c>null</c>.</returns>
|
||||
public static AlarmEventArgs? Transition(
|
||||
MTConnectConditionState? delivered,
|
||||
/// <param name="resync">True on a (re)subscribe: re-assert an unchanged Bad quality.</param>
|
||||
/// <returns>The event to raise (or <c>null</c>) and what the server has been told afterwards.</returns>
|
||||
public static (AlarmEventArgs? Event, MTConnectToldCondition Told) Transition(
|
||||
MTConnectToldCondition? told,
|
||||
MTConnectConditionState current,
|
||||
string conditionId,
|
||||
string dataItemId,
|
||||
@@ -67,34 +84,47 @@ internal static class MTConnectConditionAlarmMapper
|
||||
{
|
||||
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
|
||||
? Build(current, delivered, conditionId, dataItemId, handle, AlarmTransitionKind.Unspecified)
|
||||
: null;
|
||||
// State untouched; only the quality moves (or is re-asserted on a resync).
|
||||
var next = new MTConnectToldCondition(statement, QualityBad: true);
|
||||
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)
|
||||
{
|
||||
return Build(current, delivered, conditionId, dataItemId, handle,
|
||||
wasActive ? AlarmTransitionKind.Retrigger : AlarmTransitionKind.Raise);
|
||||
return (Build(current, statement, conditionId, dataItemId, handle,
|
||||
wasActive ? AlarmTransitionKind.Retrigger : AlarmTransitionKind.Raise), told2);
|
||||
}
|
||||
|
||||
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 — the host's connectivity annotation already confirms a materialised condition Good.
|
||||
if (delivered is null && current.Level == MTConnectConditionLevel.Normal)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Build(current, delivered, conditionId, dataItemId, handle, AlarmTransitionKind.Unspecified);
|
||||
// Normal after nothing / after Normal: nothing to say about the state — the host's connectivity
|
||||
// annotation confirms a materialised condition Good — except lifting a Bad this driver put there.
|
||||
return bad
|
||||
? (QualityOnly(current, current, conditionId, dataItemId, handle, Good), told2)
|
||||
: (null, told2);
|
||||
}
|
||||
|
||||
/// <summary>The Part 9 severity of an active level.</summary>
|
||||
@@ -124,19 +154,46 @@ internal static class MTConnectConditionAlarmMapper
|
||||
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(
|
||||
MTConnectConditionState current,
|
||||
MTConnectConditionState? delivered,
|
||||
MTConnectConditionState? previous,
|
||||
string conditionId,
|
||||
string dataItemId,
|
||||
IAlarmSubscriptionHandle handle,
|
||||
AlarmTransitionKind kind)
|
||||
{
|
||||
// An inactive statement keeps the severity of the alarm it clears (or the one last shown), so a clear
|
||||
// does not visibly re-grade the condition; a never-active condition reports Warning's 500.
|
||||
// A clear keeps the severity of the alarm it clears, so it does not visibly re-grade the condition.
|
||||
var severity = current.IsActive
|
||||
? SeverityOf(current.Level)
|
||||
: delivered?.IsActive == true ? SeverityOf(delivered.Level) : WarningSeverity;
|
||||
: previous?.IsActive == true ? SeverityOf(previous.Level) : WarningSeverity;
|
||||
|
||||
return new AlarmEventArgs(
|
||||
SubscriptionHandle: handle,
|
||||
@@ -148,6 +205,7 @@ internal static class MTConnectConditionAlarmMapper
|
||||
SourceTimestampUtc: current.TimestampUtc,
|
||||
Kind: kind,
|
||||
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>
|
||||
/// 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
|
||||
/// text all agree (a new code, an escalation, or a changed message is a new statement); two inactive
|
||||
/// states match on the level alone, so a re-reported Normal never re-clears. The timestamp and the
|
||||
/// active count are deliberately NOT part of it: a replayed <c>/current</c> after a reconnect carries the
|
||||
/// same condition under a new sequence, and re-raising it would un-acknowledge an operator's ack.
|
||||
/// states match when level, <c>nativeCode</c>, <c>nativeSeverity</c> and <c>qualifier</c> agree (a new
|
||||
/// code or an escalation is a new statement); two inactive states match on the level alone, so a
|
||||
/// re-reported Normal never re-clears. NOT part of it: the timestamp and the active count (a replayed
|
||||
/// <c>/current</c> after a reconnect carries the same condition under a new sequence, and re-raising it
|
||||
/// 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>
|
||||
/// <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>
|
||||
@@ -53,8 +55,7 @@ internal sealed record MTConnectConditionState(
|
||||
|
||||
return string.Equals(Detail.NativeCode, other.Detail.NativeCode, StringComparison.Ordinal)
|
||||
&& string.Equals(Detail.NativeSeverity, other.Detail.NativeSeverity, StringComparison.Ordinal)
|
||||
&& string.Equals(Detail.Qualifier, other.Detail.Qualifier, StringComparison.Ordinal)
|
||||
&& string.Equals(Detail.Text, other.Detail.Text, StringComparison.Ordinal);
|
||||
&& string.Equals(Detail.Qualifier, other.Detail.Qualifier, 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
|
||||
/// 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>
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -92,7 +94,6 @@ internal sealed class MTConnectConditionTracker
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Dictionary<string, ItemState> _items = new(StringComparer.Ordinal);
|
||||
private long _order;
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
// Most severe wins; among equals, the most recently activated — the newest statement the
|
||||
// operator has not yet seen.
|
||||
// Most severe wins; among equals, a DETERMINISTIC tie-break (ordinal nativeCode, then type) —
|
||||
// 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
|
||||
.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();
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -48,11 +48,19 @@ public sealed partial class MTConnectDriver : IAlarmSource
|
||||
private readonly Lock _alarmLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// ConditionId (RawPath) → the state the server was last told. Guarded by <see cref="_alarmLock"/>.
|
||||
/// Survives re-subscribes and lifecycle restarts on purpose — it describes the SERVER's condition node,
|
||||
/// which outlives both.
|
||||
/// ConditionId (RawPath) → what the server was last told. Guarded by <see cref="_alarmLock"/>. Survives a
|
||||
/// re-subscribe with the SAME reference set (a reconnect) and lifecycle restarts — the server's condition
|
||||
/// 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>
|
||||
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>
|
||||
private MTConnectConditionTracker _conditions = new();
|
||||
@@ -100,12 +108,16 @@ public sealed partial class MTConnectDriver : IAlarmSource
|
||||
|
||||
lock (_alarmLock)
|
||||
{
|
||||
// A condition that left the set has its node torn down by the server; if it comes back it is a NEW
|
||||
// node that has been told nothing, so forget what its predecessor was told.
|
||||
foreach (var stale in _deliveredConditions.Keys.Where(k => !references.Contains(k)).ToList())
|
||||
// A CHANGED set means an alarm-bearing delta, which the server applies as a full rebuild: every
|
||||
// condition node — including ones still in the set — was recreated inactive and has been told
|
||||
// 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);
|
||||
@@ -192,10 +204,10 @@ public sealed partial class MTConnectDriver : IAlarmSource
|
||||
var current = tracker.Get(dataItemId);
|
||||
if (current is null) continue;
|
||||
|
||||
_deliveredConditions.TryGetValue(conditionId, out var delivered);
|
||||
var evt = MTConnectConditionAlarmMapper.Transition(
|
||||
delivered, current, conditionId, dataItemId, subscription.Handle, resync);
|
||||
_deliveredConditions[conditionId] = current;
|
||||
_deliveredConditions.TryGetValue(conditionId, out var told);
|
||||
var (evt, nowTold) = MTConnectConditionAlarmMapper.Transition(
|
||||
told, current, conditionId, dataItemId, subscription.Handle, resync);
|
||||
_deliveredConditions[conditionId] = nowTold;
|
||||
|
||||
if (evt is null) continue;
|
||||
|
||||
|
||||
@@ -1789,6 +1789,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
_localNode, msg.DriverInstanceId, msg.Args.ConditionId);
|
||||
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
|
||||
// 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
|
||||
@@ -1811,11 +1830,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
|
||||
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
|
||||
|
||||
// Only the Primary publishes the cluster-wide alerts transition (see the gate above). A quality-only
|
||||
// annotation (#626: e.g. an MTConnect condition gone Unavailable while already Normal) is not an
|
||||
// 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;
|
||||
// Only the Primary publishes the cluster-wide alerts transition (see the gate above).
|
||||
if (!serviceAlertsAsPrimary) continue;
|
||||
|
||||
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
|
||||
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null,
|
||||
|
||||
Reference in New Issue
Block a user