fix(alarms): native-alarm re-statements no longer duplicate history or hide acked alarms (#656)
- AlarmTransitionKind.ActiveAcknowledged (appended): active AND acked applied as ONE projection. Galaxy ActiveAcked snapshot entries and OpcUaClient inactive->(active,acked) use it; the projector writes 'Activated' only if not already active, 'Acknowledged' only if not acked. - AlarmEventArgs.IsRestatement (optional, default false): set only by Galaxy snapshot entries and OpcUaClient first sight. A flagged Retrigger on an Active+unacked condition with the same message and severity, or a flagged Clear on an inactive condition, writes no /alerts row. - NativeAlarmProjector reports the history rows per transition; ForwardNativeAlarm writes one row per entry. Unflagged sources (FOCAS/MTConnect/AbCip, live Galaxy) are unchanged. - Tests: projector rules each with a must-write control; OpcUaClient transition table; Galaxy feed/driver mapping; Galaxy + OpcUaClient end-to-end through DriverHostActor. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -102,6 +102,14 @@ public sealed record AlarmAcknowledgeRequest(
|
||||
/// <b>quality-only annotation</b> (<see cref="IsQualityOnly"/>): the host updates (via its Quality-only node path) the condition's
|
||||
/// Quality without touching its Active/Acked state and publishes no <c>alerts</c> row for it.
|
||||
/// </param>
|
||||
/// <param name="IsRestatement">
|
||||
/// Gitea #656 — true when the source is RE-STATING upstream's current state rather than reporting a transition it
|
||||
/// watched happen: a reconnect snapshot entry (Galaxy) or the first event for a condition after a driver start or
|
||||
/// deploy respawn (OpcUaClient). It governs <b>history only, never node state</b>: the host still projects the
|
||||
/// state, but writes no <c>alerts</c> row when the re-statement changes nothing the condition shows (a Retrigger on
|
||||
/// an Active+unacked condition with the same message and severity; a Clear on an already-inactive condition).
|
||||
/// <c>false</c> (the default, every source that does not opt in) keeps every transition's row.
|
||||
/// </param>
|
||||
public sealed record AlarmEventArgs(
|
||||
IAlarmSubscriptionHandle SubscriptionHandle,
|
||||
string SourceNodeId,
|
||||
@@ -115,7 +123,8 @@ public sealed record AlarmEventArgs(
|
||||
string? AlarmCategory = null,
|
||||
AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified,
|
||||
int? OpcUaSeverity = null,
|
||||
uint? QualityStatusCode = null)
|
||||
uint? QualityStatusCode = null,
|
||||
bool IsRestatement = false)
|
||||
{
|
||||
/// <summary>
|
||||
/// True when this event only re-annotates the condition's source-data quality — a
|
||||
@@ -132,5 +141,8 @@ public enum AlarmSeverity { Low, Medium, High, Critical }
|
||||
/// Kind of alarm state change carried by <see cref="AlarmEventArgs.Kind"/>, letting a
|
||||
/// consumer derive OPC UA Part 9 active/ack state. Mirrors the driver-side
|
||||
/// transition-kind enums (e.g. Galaxy's <c>GalaxyAlarmTransitionKind</c>).
|
||||
/// <see cref="ActiveAcknowledged"/> (Gitea #656) means "the condition is active AND acknowledged", applied by the
|
||||
/// host as ONE projection — never Raise-then-Acknowledge, which would show clients an Active+unacked intermediate.
|
||||
/// Members are append-only: their numeric values must not move.
|
||||
/// </summary>
|
||||
public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger }
|
||||
public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger, ActiveAcknowledged }
|
||||
|
||||
@@ -1415,8 +1415,11 @@ public sealed class GalaxyDriver
|
||||
GalaxyAlarmTransitionKind.Acknowledge => Core.Abstractions.AlarmTransitionKind.Acknowledge,
|
||||
GalaxyAlarmTransitionKind.Clear => Core.Abstractions.AlarmTransitionKind.Clear,
|
||||
GalaxyAlarmTransitionKind.Retrigger => Core.Abstractions.AlarmTransitionKind.Retrigger,
|
||||
GalaxyAlarmTransitionKind.ActiveAcknowledged => Core.Abstractions.AlarmTransitionKind.ActiveAcknowledged,
|
||||
_ => Core.Abstractions.AlarmTransitionKind.Unspecified,
|
||||
});
|
||||
},
|
||||
// #656: a snapshot entry re-states upstream state — history-only signal for the host.
|
||||
IsRestatement: transition.IsRestatement);
|
||||
try
|
||||
{
|
||||
OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
@@ -10,6 +10,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
/// on the OPC UA Part 9 condition once <c>AlarmEventArgs</c> is extended in
|
||||
/// the client-surface refresh PR (E.7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>IsRestatement</c> (Gitea #656) is true for an active-alarm SNAPSHOT entry — upstream re-stating its current
|
||||
/// state on (re)connect, not a transition the gateway watched happen. It rides to
|
||||
/// <see cref="AlarmEventArgs.IsRestatement"/> so the host writes no duplicate history row for it.
|
||||
/// </remarks>
|
||||
internal sealed record GalaxyAlarmTransition(
|
||||
string AlarmFullReference,
|
||||
string SourceObjectReference,
|
||||
@@ -23,7 +28,8 @@ internal sealed record GalaxyAlarmTransition(
|
||||
string OperatorUser,
|
||||
string OperatorComment,
|
||||
string Category,
|
||||
string Description);
|
||||
string Description,
|
||||
bool IsRestatement = false);
|
||||
|
||||
/// <summary>Kind of alarm state change observed by <see cref="EventPump"/>.</summary>
|
||||
internal enum GalaxyAlarmTransitionKind
|
||||
@@ -33,4 +39,10 @@ internal enum GalaxyAlarmTransitionKind
|
||||
Acknowledge = 2,
|
||||
Clear = 3,
|
||||
Retrigger = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #656 — the alarm is active AND acknowledged (a snapshot <c>ActiveAcked</c> entry). Maps to
|
||||
/// <see cref="AlarmTransitionKind.ActiveAcknowledged"/>, which the host applies as one projection.
|
||||
/// </summary>
|
||||
ActiveAcknowledged = 5,
|
||||
}
|
||||
|
||||
@@ -172,18 +172,26 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
|
||||
/// alarm is surfaced as a transition so the OPC UA Part 9 condition layer sees
|
||||
/// the alarm's present state on (re)connect: an unacknowledged active alarm as
|
||||
/// a <see cref="GalaxyAlarmTransitionKind.Retrigger"/>, an acknowledged one as a
|
||||
/// <see cref="GalaxyAlarmTransitionKind.Acknowledge"/>.
|
||||
/// <see cref="GalaxyAlarmTransitionKind.ActiveAcknowledged"/>, an inactive one as a
|
||||
/// <see cref="GalaxyAlarmTransitionKind.Clear"/>. Every entry is flagged
|
||||
/// <see cref="GalaxyAlarmTransition.IsRestatement"/>.
|
||||
/// <para>Retrigger, not Raise (Gitea #647 review): the host treats a Raise on a condition it already
|
||||
/// shows Active as a re-statement that KEEPS the acknowledgement. A snapshot re-states upstream state and
|
||||
/// the upstream's ack state is authoritative, so an unacked entry must un-ack a host condition still
|
||||
/// holding an ack from before the reconnect.</para>
|
||||
/// <para>ActiveAcknowledged, not Acknowledge (Gitea #656): an Acknowledge keeps the host's prior Active
|
||||
/// state, so an acked active alarm the host did not already show active (a cold start, or one that went
|
||||
/// active while the gateway was unreachable) projected INACTIVE+acked — invisible to clients.</para>
|
||||
/// <para>IsRestatement (Gitea #656): every gateway reconnect replays the snapshot, so without the flag each
|
||||
/// replay wrote a duplicate "Activated" (or "Cleared") history row for an alarm whose node did not change.
|
||||
/// Live <see cref="DispatchTransition"/> events are never flagged — a live Retrigger is a real re-occurrence.</para>
|
||||
/// </summary>
|
||||
private void DispatchSnapshotEntry(ActiveAlarmSnapshot snapshot)
|
||||
{
|
||||
var kind = snapshot.CurrentState switch
|
||||
{
|
||||
AlarmConditionState.Active => GalaxyAlarmTransitionKind.Retrigger,
|
||||
AlarmConditionState.ActiveAcked => GalaxyAlarmTransitionKind.Acknowledge,
|
||||
AlarmConditionState.ActiveAcked => GalaxyAlarmTransitionKind.ActiveAcknowledged,
|
||||
AlarmConditionState.Inactive => GalaxyAlarmTransitionKind.Clear,
|
||||
_ => GalaxyAlarmTransitionKind.Unspecified,
|
||||
};
|
||||
@@ -210,7 +218,8 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
|
||||
OperatorUser: snapshot.OperatorUser,
|
||||
OperatorComment: snapshot.OperatorComment,
|
||||
Category: snapshot.Category,
|
||||
Description: snapshot.Description));
|
||||
Description: snapshot.Description,
|
||||
IsRestatement: true));
|
||||
}
|
||||
|
||||
private void DispatchTransition(OnAlarmTransitionEvent body)
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests"/>
|
||||
<!-- Gitea #656: the native-alarm re-statement end-to-end tests drive this driver into the real DriverHostActor. -->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Runtime.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1953,8 +1953,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
if (prior == told) return;
|
||||
kinds = [AlarmTransitionKind.Unspecified];
|
||||
}
|
||||
// #656: on first sight (a start or deploy respawn) this instance is RE-STATING upstream's current state,
|
||||
// not reporting a transition it watched — flagged so the host writes no duplicate history row when its
|
||||
// condition already shows exactly this. History only; the host still projects the state.
|
||||
foreach (var kind in kinds)
|
||||
OnAlarmEvent?.Invoke(this, e with { Kind = kind });
|
||||
OnAlarmEvent?.Invoke(this, e with { Kind = kind, IsRestatement = !priorKnown });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2066,7 +2069,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <summary>
|
||||
/// #619 review — the <see cref="AlarmTransitionKind"/> sequence that moves the host's
|
||||
/// <c>NativeAlarmProjector</c> (which knows only kinds: Raise ⇒ active+unacked — or, on an already-active condition, a re-statement keeping the ack (#647) — Acknowledge ⇒ acked,
|
||||
/// Clear ⇒ inactive keeping acked) from <paramref name="prior"/> to <paramref name="target"/>. A single
|
||||
/// ActiveAcknowledged ⇒ active+acked in one step (#656), Clear ⇒ inactive keeping acked) from <paramref name="prior"/> to <paramref name="target"/>. A single
|
||||
/// upstream event can need two (an alarm that went active and was acknowledged between two notifications).
|
||||
/// No state change ⇒ empty (the caller decides whether a message/severity change warrants an Unspecified).
|
||||
/// </summary>
|
||||
@@ -2097,10 +2100,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
{
|
||||
// Active+unacked: a fresh raise, or a re-arm of an active-acked condition.
|
||||
(true, false) => [prior.Active ? AlarmTransitionKind.Retrigger : AlarmTransitionKind.Raise],
|
||||
// Active+acked: raise first when it was inactive (Raise always lands unacked).
|
||||
// Active+acked from inactive: ONE atomic ActiveAcknowledged (Gitea #656) — Raise-then-Acknowledge showed
|
||||
// clients an Active+UNacked intermediate event before the ack.
|
||||
(true, true) => prior.Active
|
||||
? [AlarmTransitionKind.Acknowledge]
|
||||
: [AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge],
|
||||
: [AlarmTransitionKind.ActiveAcknowledged],
|
||||
// Inactive+acked: Clear keeps the prior ack, so ack after it when that was unacked.
|
||||
(false, true) => prior.Active
|
||||
? prior.Acked ? [AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge]
|
||||
|
||||
+2
@@ -29,6 +29,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests"/>
|
||||
<!-- Gitea #656: the native-alarm re-statement end-to-end tests drive this driver into the real DriverHostActor. -->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Runtime.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1877,49 +1877,74 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
foreach (var n in nodeIds)
|
||||
{
|
||||
var nodeId = n.NodeId;
|
||||
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args, out var restatement);
|
||||
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args, out IReadOnlyList<AlarmTransitionKind> historyKinds);
|
||||
_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 re-statement
|
||||
// (Gitea #647 review: a Raise on an already-active condition, sent by a driver a deploy respawned) is
|
||||
// not a transition — the condition was already Active, so a second "Activated" row would be a duplicate.
|
||||
if (!serviceAlertsAsPrimary || restatement) continue;
|
||||
// Only the Primary publishes the cluster-wide alerts transition (see the gate above). The projector
|
||||
// decides WHICH rows the transition warrants: none for a re-statement (Gitea #647 review: a Raise on an
|
||||
// already-active condition from a respawned driver; #656: a flagged snapshot/first-sight Retrigger or
|
||||
// Clear that changes nothing the condition shows), and up to two for an ActiveAcknowledged (#656:
|
||||
// "Activated" only if it was not already active, "Acknowledged" only if it was not already acked).
|
||||
if (!serviceAlertsAsPrimary) continue;
|
||||
|
||||
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
|
||||
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null,
|
||||
ReferencingEquipmentPaths: (IReadOnlyList<string>)Array.Empty<string>());
|
||||
var alertEvent = new AlarmTransitionEvent(
|
||||
AlarmId: nodeId,
|
||||
EquipmentPath: meta.EquipmentId,
|
||||
AlarmName: meta.Name,
|
||||
TransitionKind: ToEventKind(msg.Args.Kind),
|
||||
// The projector mapped the four-bucket AlarmSeverity onto the OPC UA 1..1000 scale already;
|
||||
// reuse its ushort so the condition node + the alerts row agree on severity.
|
||||
Severity: snapshot.Severity,
|
||||
Message: msg.Args.Message,
|
||||
// Native transitions are device-driven; an operator comment only rides along on an
|
||||
// acknowledge from the upstream alarm system. "device" marks a non-operator origin.
|
||||
User: msg.Args.OperatorComment is null ? string.Empty : "device",
|
||||
TimestampUtc: msg.Args.SourceTimestampUtc,
|
||||
AlarmTypeName: meta.AlarmType,
|
||||
Comment: msg.Args.OperatorComment,
|
||||
// Per-condition opt-out parsed from TagConfig.alarm.historizeToAveva (bool?, null ⇒ absent ⇒
|
||||
// historize). The HistorianAdapterActor gate (historizeToAveva is not false) historizes null +
|
||||
// true and suppresses the durable AVEVA row only on an explicit false — the same posture as the
|
||||
// scripted-alarm opt-out. null here rides through unchanged (the gate treats it as default-on).
|
||||
HistorizeToAveva: meta.HistorizeToAveva,
|
||||
// WP4: the Area/Line/Equipment UNS paths that reference this raw condition — the /alerts row
|
||||
// renders them as the equipment list (empty for a raw condition no equipment references yet).
|
||||
ReferencingEquipmentPaths: meta.ReferencingEquipmentPaths);
|
||||
_mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, alertEvent));
|
||||
// Phase 5: fan the same transition into the node-local live-telemetry hub (no-op until a gRPC
|
||||
// client subscribes). The DPS publish above is unchanged — a strictly additive tap, riding the
|
||||
// same Primary gate (only reached when serviceAlertsAsPrimary).
|
||||
_telemetryHub?.Emit(new TelemetryItem.Alarm(alertEvent));
|
||||
foreach (var historyKind in historyKinds)
|
||||
PublishNativeAlertRow(msg.Args, nodeId, snapshot, meta, historyKind);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publish one native-alarm <c>alerts</c> row (DPS + the node-local telemetry hub). Primary-gated by the caller.
|
||||
/// </summary>
|
||||
/// <param name="args">The native transition.</param>
|
||||
/// <param name="nodeId">The condition node id.</param>
|
||||
/// <param name="snapshot">The projected snapshot (its severity is the row's).</param>
|
||||
/// <param name="meta">The condition's authored metadata.</param>
|
||||
/// <param name="historyKind">The row's transition kind (one of the projector's history kinds).</param>
|
||||
private void PublishNativeAlertRow(
|
||||
AlarmEventArgs args, string nodeId, AlarmConditionSnapshot snapshot,
|
||||
(string EquipmentId, string Name, string AlarmType, bool? HistorizeToAveva, IReadOnlyList<string> ReferencingEquipmentPaths) meta,
|
||||
AlarmTransitionKind historyKind)
|
||||
{
|
||||
// #656: an ActiveAcknowledged expands to "Activated" + "Acknowledged". The operator comment/user belong to
|
||||
// the acknowledgement, and the activation happened at the original raise time when the source knows it.
|
||||
var expandedActivation = args.Kind == AlarmTransitionKind.ActiveAcknowledged
|
||||
&& historyKind != AlarmTransitionKind.Acknowledge;
|
||||
var comment = expandedActivation ? null : args.OperatorComment;
|
||||
var timestamp = expandedActivation ? args.OriginalRaiseTimestampUtc ?? args.SourceTimestampUtc : args.SourceTimestampUtc;
|
||||
var alertEvent = new AlarmTransitionEvent(
|
||||
AlarmId: nodeId,
|
||||
EquipmentPath: meta.EquipmentId,
|
||||
AlarmName: meta.Name,
|
||||
TransitionKind: ToEventKind(historyKind),
|
||||
// The projector mapped the four-bucket AlarmSeverity onto the OPC UA 1..1000 scale already;
|
||||
// reuse its ushort so the condition node + the alerts row agree on severity.
|
||||
Severity: snapshot.Severity,
|
||||
Message: args.Message,
|
||||
// Native transitions are device-driven; an operator comment only rides along on an
|
||||
// acknowledge from the upstream alarm system. "device" marks a non-operator origin.
|
||||
User: comment is null ? string.Empty : "device",
|
||||
TimestampUtc: timestamp,
|
||||
AlarmTypeName: meta.AlarmType,
|
||||
Comment: comment,
|
||||
// Per-condition opt-out parsed from TagConfig.alarm.historizeToAveva (bool?, null ⇒ absent ⇒
|
||||
// historize). The HistorianAdapterActor gate (historizeToAveva is not false) historizes null +
|
||||
// true and suppresses the durable AVEVA row only on an explicit false — the same posture as the
|
||||
// scripted-alarm opt-out. null here rides through unchanged (the gate treats it as default-on).
|
||||
HistorizeToAveva: meta.HistorizeToAveva,
|
||||
// WP4: the Area/Line/Equipment UNS paths that reference this raw condition — the /alerts row
|
||||
// renders them as the equipment list (empty for a raw condition no equipment references yet).
|
||||
ReferencingEquipmentPaths: meta.ReferencingEquipmentPaths);
|
||||
_mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, alertEvent));
|
||||
// Phase 5: fan the same transition into the node-local live-telemetry hub (no-op until a gRPC
|
||||
// client subscribes). The DPS publish above is unchanged — a strictly additive tap, riding the
|
||||
// same Primary gate (only reached when serviceAlertsAsPrimary).
|
||||
_telemetryHub?.Emit(new TelemetryItem.Alarm(alertEvent));
|
||||
}
|
||||
|
||||
/// <summary>Maps a native <see cref="AlarmTransitionKind"/> onto the canonical alarm event-kind
|
||||
/// vocabulary scripted alarms emit (the <c>EmissionKind</c> names) so a native row renders with the
|
||||
/// correct chip on the <c>/alerts</c> page and historizes into the same <c>EventKind</c> column as
|
||||
@@ -1930,6 +1955,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => "Activated",
|
||||
AlarmTransitionKind.Clear => "Cleared",
|
||||
AlarmTransitionKind.Acknowledge => "Acknowledged",
|
||||
// #656: never reached in practice — the projector expands ActiveAcknowledged into Raise + Acknowledge rows.
|
||||
AlarmTransitionKind.ActiveAcknowledged => "Acknowledged",
|
||||
_ => "Activated",
|
||||
};
|
||||
|
||||
|
||||
@@ -12,48 +12,110 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
/// </summary>
|
||||
public sealed class NativeAlarmProjector
|
||||
{
|
||||
private readonly Dictionary<string, (bool Active, bool Acked, string? Message)> _prior = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, (bool Active, bool Acked, string? Message, ushort Severity)> _prior =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Project an alarm transition onto the full condition snapshot for <paramref name="nodeId"/>.</summary>
|
||||
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
|
||||
/// <param name="e">The native alarm transition.</param>
|
||||
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
|
||||
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) => Project(nodeId, e, out _);
|
||||
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) => Project(nodeId, e, out IReadOnlyList<AlarmTransitionKind> _);
|
||||
|
||||
/// <summary>
|
||||
/// Project an alarm transition, reporting whether it was a <b>re-statement</b> (Gitea #647 review): a
|
||||
/// <see cref="AlarmTransitionKind.Raise"/> on a condition already projected Active WITH THE SAME MESSAGE
|
||||
/// (re-review R1 — a different message is a different alarm, and un-acks). That is what a driver
|
||||
/// whose told-record died with its instance sends after a deploy respawns it — the alarm it re-reads is the
|
||||
/// one the server already shows, so the acknowledgement is KEPT and the host writes no second "Activated"
|
||||
/// row. Only <see cref="AlarmTransitionKind.Retrigger"/> (or inactive → active) un-acknowledges; a source
|
||||
/// that knows the alarm is a genuinely new statement must send Retrigger.
|
||||
/// Project an alarm transition, reporting whether it was a <b>re-statement</b> — a transition that writes no
|
||||
/// <c>alerts</c> row (see <see cref="Project(string, AlarmEventArgs, out IReadOnlyList{AlarmTransitionKind})"/>).
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
|
||||
/// <param name="e">The native alarm transition.</param>
|
||||
/// <param name="restatement">True when <paramref name="e"/> re-stated an already-active condition.</param>
|
||||
/// <param name="restatement">True when <paramref name="e"/> produced no history row.</param>
|
||||
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
|
||||
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e, out bool restatement)
|
||||
{
|
||||
var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true, Message: (string?)null);
|
||||
var snapshot = Project(nodeId, e, out IReadOnlyList<AlarmTransitionKind> historyKinds);
|
||||
restatement = historyKinds.Count == 0;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project an alarm transition and report the <c>alerts</c> history rows it warrants, in order. Node state
|
||||
/// and history are decided separately: a transition always projects a snapshot, but only a transition that
|
||||
/// actually CHANGED what the condition shows (or that a source reports as genuinely new) writes a row.
|
||||
/// <list type="bullet">
|
||||
/// <item>Gitea #647 review — a <see cref="AlarmTransitionKind.Raise"/> on a condition already projected
|
||||
/// Active WITH THE SAME MESSAGE (re-review R1 — a different message is a different alarm, and un-acks) is a
|
||||
/// re-statement: what a driver whose told-record died with its instance sends after a deploy respawns
|
||||
/// it. The acknowledgement is KEPT and no row is written. Only <see cref="AlarmTransitionKind.Retrigger"/>
|
||||
/// (or inactive → active) un-acknowledges.</item>
|
||||
/// <item>Gitea #656 — <see cref="AlarmTransitionKind.ActiveAcknowledged"/> projects Active+acked in ONE
|
||||
/// update (never an Active+unacked intermediate a client could see) and writes "Activated" only when the
|
||||
/// condition was not already showing that alarm active, then "Acknowledged" only when it was not acked.</item>
|
||||
/// <item>Gitea #656 — a <see cref="AlarmEventArgs.IsRestatement"/> event (a source re-stating upstream's
|
||||
/// current state: a reconnect snapshot, first sight after a respawn) writes no row when it changes nothing
|
||||
/// the condition shows: a Retrigger on an Active+unacked condition with the same message AND severity, or
|
||||
/// a Clear on an already-inactive condition. Unflagged sources (FOCAS, MTConnect, AbCip, live Galaxy
|
||||
/// transitions) are unaffected — a live Retrigger is a genuine re-occurrence and keeps its row.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The materialised condition node's id (the projection's state key).</param>
|
||||
/// <param name="e">The native alarm transition.</param>
|
||||
/// <param name="historyKinds">
|
||||
/// The <c>alerts</c> rows to write, in order — each a <see cref="AlarmTransitionKind.Raise"/>/<see cref="AlarmTransitionKind.Retrigger"/>
|
||||
/// ("Activated"), <see cref="AlarmTransitionKind.Acknowledge"/>, <see cref="AlarmTransitionKind.Clear"/> or
|
||||
/// <see cref="AlarmTransitionKind.Unspecified"/>. Empty when the transition is a re-statement.
|
||||
/// </param>
|
||||
/// <returns>The full Part 9 condition snapshot to write to the node.</returns>
|
||||
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e, out IReadOnlyList<AlarmTransitionKind> historyKinds)
|
||||
{
|
||||
var prev = _prior.TryGetValue(nodeId, out var p)
|
||||
? p : (Active: false, Acked: true, Message: (string?)null, Severity: (ushort)0);
|
||||
// #626: a source with a finer scale than the four buckets supplies the exact Part 9 severity.
|
||||
var sev = e.OpcUaSeverity is { } exact ? (ushort)Math.Clamp(exact, 1, 1000) : MapSeverity(e.Severity);
|
||||
// Gitea #647 re-review R1: only a Raise saying the SAME thing the condition already shows is a re-statement.
|
||||
// Gitea #647 re-review R1: only a statement saying the SAME thing the condition already shows is a re-statement.
|
||||
// A respawned driver cannot tell "the alarm I was showing" from "a different one that appeared while I was
|
||||
// down", so a different message is treated as a new statement — un-acked, with its alerts row. Comparing the
|
||||
// message is conservative: any difference (a sources' messages are deterministic for an unchanged statement)
|
||||
// errs toward showing the operator an unacknowledged alarm, never toward hiding one behind a stale ack.
|
||||
restatement = e.Kind == AlarmTransitionKind.Raise && prev.Active
|
||||
var sameMessage = prev.Active
|
||||
&& string.Equals(prev.Message?.Trim(), e.Message.Trim(), StringComparison.Ordinal);
|
||||
var (active, acked) = e.Kind switch
|
||||
bool active, acked;
|
||||
switch (e.Kind)
|
||||
{
|
||||
AlarmTransitionKind.Raise when restatement => (true, prev.Acked),
|
||||
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
|
||||
AlarmTransitionKind.Acknowledge => (prev.Active, true),
|
||||
AlarmTransitionKind.Clear => (false, prev.Acked),
|
||||
_ => (prev.Active, prev.Acked),
|
||||
};
|
||||
_prior[nodeId] = (active, acked, e.Message);
|
||||
case AlarmTransitionKind.Raise when sameMessage:
|
||||
(active, acked, historyKinds) = (true, prev.Acked, []);
|
||||
break;
|
||||
// #656: a source re-stating an Active+unacked condition that already shows exactly this (message AND
|
||||
// severity — an MTConnect Warning→Fault escalation can keep the text) changes nothing the node shows.
|
||||
case AlarmTransitionKind.Retrigger when e.IsRestatement && sameMessage && !prev.Acked && prev.Severity == sev:
|
||||
(active, acked, historyKinds) = (true, false, []);
|
||||
break;
|
||||
case AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger:
|
||||
(active, acked, historyKinds) = (true, false, [e.Kind]);
|
||||
break;
|
||||
case AlarmTransitionKind.ActiveAcknowledged:
|
||||
(active, acked) = (true, true);
|
||||
historyKinds = !sameMessage
|
||||
? [AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]
|
||||
: !prev.Acked ? [AlarmTransitionKind.Acknowledge] : [];
|
||||
break;
|
||||
case AlarmTransitionKind.Acknowledge:
|
||||
(active, acked, historyKinds) = (prev.Active, true, [AlarmTransitionKind.Acknowledge]);
|
||||
break;
|
||||
// #656: a re-stated Clear of a condition that is already inactive changes nothing (Galaxy replays every
|
||||
// Inactive snapshot entry on each reconnect).
|
||||
case AlarmTransitionKind.Clear when e.IsRestatement && !prev.Active:
|
||||
(active, acked, historyKinds) = (false, prev.Acked, []);
|
||||
break;
|
||||
case AlarmTransitionKind.Clear:
|
||||
(active, acked, historyKinds) = (false, prev.Acked, [AlarmTransitionKind.Clear]);
|
||||
break;
|
||||
default:
|
||||
// Unspecified (a message/severity-only update) or an unknown future kind: keep the state. The row is
|
||||
// written as before (Unspecified renders "Activated") — unchanged behaviour.
|
||||
(active, acked, historyKinds) = (prev.Active, prev.Acked, [e.Kind]);
|
||||
break;
|
||||
}
|
||||
|
||||
_prior[nodeId] = (active, acked, e.Message, sev);
|
||||
return new AlarmConditionSnapshot(
|
||||
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message,
|
||||
@@ -71,7 +133,7 @@ public sealed class NativeAlarmProjector
|
||||
/// <param name="nodeId">The acknowledged condition node id.</param>
|
||||
public void MarkAcknowledged(string nodeId)
|
||||
{
|
||||
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = (p.Active, true, p.Message);
|
||||
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = p with { Acked = true };
|
||||
}
|
||||
|
||||
/// <summary>Clears all tracked per-node state.</summary>
|
||||
|
||||
+39
@@ -84,6 +84,45 @@ public sealed class GalaxyDriverAlarmEventArgsExtensionTests
|
||||
observed[0].AlarmCategory.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #656 — a snapshot ActiveAcknowledged transition reaches <see cref="AlarmEventArgs"/> as
|
||||
/// <see cref="AlarmTransitionKind.ActiveAcknowledged"/> (not Unspecified, which would keep the host's prior
|
||||
/// state) with <see cref="AlarmEventArgs.IsRestatement"/> carried through; an unflagged transition stays false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ActiveAcknowledged_snapshot_transition_maps_kind_and_restatement_flag()
|
||||
{
|
||||
var feed = new FakeAlarmFeed();
|
||||
using var driver = NewDriver(feed);
|
||||
|
||||
await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
||||
var observed = new List<AlarmEventArgs>();
|
||||
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
||||
|
||||
var t = new GalaxyAlarmTransition(
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
SourceObjectReference: "Tank01",
|
||||
AlarmTypeName: "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind: GalaxyAlarmTransitionKind.ActiveAcknowledged,
|
||||
SeverityBucket: AlarmSeverity.Critical,
|
||||
OpcUaSeverity: 800,
|
||||
RawMxAccessSeverity: 750,
|
||||
OriginalRaiseTimestampUtc: null,
|
||||
TransitionTimestampUtc: DateTime.UtcNow,
|
||||
OperatorUser: "alice",
|
||||
OperatorComment: "seen",
|
||||
Category: "Process",
|
||||
Description: "Tank 01 high-high level",
|
||||
IsRestatement: true);
|
||||
feed.Emit(t);
|
||||
feed.Emit(t with { TransitionKind = GalaxyAlarmTransitionKind.Retrigger, IsRestatement = false });
|
||||
|
||||
observed.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([
|
||||
(AlarmTransitionKind.ActiveAcknowledged, true),
|
||||
(AlarmTransitionKind.Retrigger, false),
|
||||
]);
|
||||
}
|
||||
|
||||
private static GalaxyDriver NewDriver(IGalaxyAlarmFeed feed)
|
||||
{
|
||||
var options = new GalaxyDriverOptions(
|
||||
|
||||
+45
-2
@@ -64,14 +64,57 @@ public sealed class GatewayGalaxyAlarmFeedTests
|
||||
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
|
||||
observed[0].RawMxAccessSeverity.ShouldBe(750);
|
||||
|
||||
// Acknowledged snapshot entry → Acknowledge, operator fields preserved.
|
||||
observed[1].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Acknowledge);
|
||||
// Acknowledged snapshot entry → ActiveAcknowledged (#656 — an Acknowledge kept the host's prior Active, so a
|
||||
// cold host projected it INACTIVE), operator fields preserved.
|
||||
observed[1].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.ActiveAcknowledged);
|
||||
observed[1].OperatorUser.ShouldBe("alice");
|
||||
observed[1].OperatorComment.ShouldBe("investigating");
|
||||
|
||||
// #656: snapshot entries re-state upstream state; the live transition is unflagged.
|
||||
observed[0].IsRestatement.ShouldBeTrue();
|
||||
observed[1].IsRestatement.ShouldBeTrue();
|
||||
|
||||
// Live transition after snapshot_complete.
|
||||
observed[2].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Clear);
|
||||
observed[2].OriginalRaiseTimestampUtc.ShouldBe(raise);
|
||||
observed[2].IsRestatement.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #656 — an Inactive snapshot entry is a re-stated Clear; a LIVE Retrigger (a genuine MXAccess
|
||||
/// re-occurrence) is never flagged, so the host keeps its history row.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Inactive_snapshot_entry_is_a_restated_clear_and_a_live_retrigger_is_unflagged()
|
||||
{
|
||||
var at = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var messages = new[]
|
||||
{
|
||||
SnapshotMessage("Tank01.Level.HiHi", AlarmConditionState.Inactive, severity: 750, lastTransition: at),
|
||||
new AlarmFeedMessage { SnapshotComplete = true },
|
||||
TransitionMessage("Tank01.Level.HiHi", AlarmTransitionKind.Retrigger, severity: 750,
|
||||
transitionTime: at.AddMinutes(1)),
|
||||
};
|
||||
|
||||
var observed = new List<GalaxyAlarmTransition>();
|
||||
var got2 = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
await using var feed = new GatewayGalaxyAlarmFeed((_, ct) => OpenStream(messages, ct), clientName: "FeedTest656");
|
||||
feed.OnAlarmTransition += (_, t) =>
|
||||
{
|
||||
lock (observed)
|
||||
{
|
||||
observed.Add(t);
|
||||
if (observed.Count == 2) got2.TrySetResult(true);
|
||||
}
|
||||
};
|
||||
feed.Start();
|
||||
|
||||
(await Task.WhenAny(got2.Task, Task.Delay(TimeSpan.FromSeconds(2)))).ShouldBe(got2.Task);
|
||||
|
||||
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Clear);
|
||||
observed[0].IsRestatement.ShouldBeTrue();
|
||||
observed[1].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Retrigger);
|
||||
observed[1].IsRestatement.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the feed drops transitions with unspecified kind and empty messages.</summary>
|
||||
|
||||
@@ -218,7 +218,8 @@ public sealed class OpcUaClientAlarmTests
|
||||
/// <summary>The transition sequence drives a projector from the driver's last-told state to the event's.</summary>
|
||||
[Theory]
|
||||
[InlineData(false, true, true, false, new[] { AlarmTransitionKind.Raise })]
|
||||
[InlineData(false, true, true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(false, true, true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })]
|
||||
[InlineData(false, false, true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })]
|
||||
[InlineData(true, false, true, true, new[] { AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(true, false, false, false, new[] { AlarmTransitionKind.Clear })]
|
||||
[InlineData(true, true, false, true, new[] { AlarmTransitionKind.Clear })]
|
||||
@@ -236,12 +237,13 @@ public sealed class OpcUaClientAlarmTests
|
||||
/// Gitea #647 review — with no prior this driver told the host (first sight after start or a deploy respawn),
|
||||
/// an unacknowledged upstream target goes out as Retrigger, never Raise: the host keeps an existing ack across
|
||||
/// a Raise on an already-active condition, so only a Retrigger makes the host match the upstream's unack.
|
||||
/// An acknowledged target keeps its Raise (a re-statement there is exactly right).
|
||||
/// An acknowledged active target is ONE atomic ActiveAcknowledged (Gitea #656 — Raise-then-Acknowledge showed
|
||||
/// clients an Active+unacked intermediate).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, false, new[] { AlarmTransitionKind.Retrigger })]
|
||||
[InlineData(false, false, new[] { AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear })]
|
||||
[InlineData(true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })]
|
||||
public void An_unknown_prior_tells_an_unacked_upstream_with_Retrigger(bool active, bool acked, AlarmTransitionKind[] expected)
|
||||
{
|
||||
OpcUaClientDriver.AlarmTransitions((false, true), (active, acked), priorKnown: false).ShouldBe(expected);
|
||||
@@ -253,6 +255,7 @@ public sealed class OpcUaClientAlarmTests
|
||||
private static (bool Active, bool Acked) Simulate((bool Active, bool Acked) s, AlarmTransitionKind k) => k switch
|
||||
{
|
||||
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
|
||||
AlarmTransitionKind.ActiveAcknowledged => (true, true),
|
||||
AlarmTransitionKind.Acknowledge => (s.Active, true),
|
||||
AlarmTransitionKind.Clear => (false, s.Acked),
|
||||
_ => s,
|
||||
@@ -375,6 +378,8 @@ public sealed class OpcUaClientAlarmTests
|
||||
// First sight of an UNACKED upstream condition is a Retrigger (#647 review: the host keeps an ack across a
|
||||
// Raise on an already-active condition, and only the upstream knows it is unacknowledged).
|
||||
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Retrigger, AlarmTransitionKind.Acknowledge]);
|
||||
// #656: only the first-sight event re-states; the ack after it is a transition this instance watched.
|
||||
seen.Select(e => e.IsRestatement).ShouldBe([true, false]);
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle); // the refresh copy
|
||||
seen.Count.ShouldBe(2);
|
||||
@@ -386,6 +391,29 @@ public sealed class OpcUaClientAlarmTests
|
||||
seen[2].Message.ShouldBe("worse");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #656 — an upstream condition first seen Active+acked goes out as ONE ActiveAcknowledged flagged as a
|
||||
/// re-statement (never Raise-then-Acknowledge, whose first event showed clients an unacked alarm); every
|
||||
/// event after first sight is unflagged, including a later re-activation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void First_sight_of_an_acked_active_condition_is_one_restated_ActiveAcknowledged()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-656");
|
||||
var seen = Record(drv);
|
||||
var handle = new FakeAlarmHandle();
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([(AlarmTransitionKind.ActiveAcknowledged, true)]);
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(false, true), TripIndex, handle);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true, message: "again"), TripIndex, handle);
|
||||
seen.Skip(1).Select(e => (e.Kind, e.IsRestatement)).ShouldBe([
|
||||
(AlarmTransitionKind.Clear, false),
|
||||
(AlarmTransitionKind.ActiveAcknowledged, false),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>The exact upstream severity rides OpcUaSeverity (#626) so the host does not re-bucket it.</summary>
|
||||
[Fact]
|
||||
public void The_exact_upstream_severity_is_carried()
|
||||
@@ -410,16 +438,16 @@ public sealed class OpcUaClientAlarmTests
|
||||
var handle = new FakeAlarmHandle();
|
||||
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Count.ShouldBe(2);
|
||||
seen.Count.ShouldBe(1);
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Count.ShouldBe(2, "same set after a reinit: the refresh copy is a duplicate");
|
||||
seen.Count.ShouldBe(1, "same set after a reinit: the refresh copy is a duplicate");
|
||||
|
||||
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1", "Plant/opc/plc/Trip2"]);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Skip(2).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
|
||||
seen.Skip(1).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.ActiveAcknowledged]);
|
||||
}
|
||||
|
||||
/// <summary>A server without ConditionRefresh is tolerated; anything else is a real failure.</summary>
|
||||
|
||||
@@ -233,16 +233,16 @@ public class NativeAlarmProjectorTests
|
||||
public void A_Raise_on_an_already_active_condition_is_a_restatement_that_keeps_the_ack()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var first).Active.ShouldBeTrue();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise), out bool first).Active.ShouldBeTrue();
|
||||
first.ShouldBeFalse("inactive → active is a real transition");
|
||||
sut.MarkAcknowledged("n1");
|
||||
|
||||
var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var restatement);
|
||||
var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out bool restatement);
|
||||
restatement.ShouldBeTrue();
|
||||
restated.Active.ShouldBeTrue();
|
||||
restated.Acknowledged.ShouldBeTrue("a re-statement must not un-acknowledge");
|
||||
|
||||
var retriggered = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out var retrigger);
|
||||
var retriggered = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out bool retrigger);
|
||||
retrigger.ShouldBeFalse();
|
||||
retriggered.Acknowledged.ShouldBeFalse("a Retrigger is a new statement and un-acks");
|
||||
}
|
||||
@@ -259,20 +259,209 @@ public class NativeAlarmProjectorTests
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"));
|
||||
sut.MarkAcknowledged("n1");
|
||||
|
||||
var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out var r1);
|
||||
var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out bool r1);
|
||||
r1.ShouldBeTrue();
|
||||
same.Acknowledged.ShouldBeTrue();
|
||||
|
||||
var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out var r2);
|
||||
var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out bool r2);
|
||||
r2.ShouldBeFalse("a different alarm is not a re-statement");
|
||||
other.Acknowledged.ShouldBeFalse("the operator has not seen this alarm");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
// Gitea #656 — ActiveAcknowledged + IsRestatement. Every "no row" test is paired with a control that MUST write.
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>#656 claim 1 — an acked active alarm on a condition the host has never seen (a cold start) projects
|
||||
/// ACTIVE+acked in one update, with an Activated row then an Acknowledged row. Pre-fix Galaxy sent Acknowledge,
|
||||
/// which projected inactive+acked: an alarm active in AVEVA was invisible to OPC UA clients.</summary>
|
||||
[Fact]
|
||||
public void ActiveAcknowledged_on_an_unknown_condition_projects_active_and_acked_with_Activated_then_Acknowledged_rows()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList<AlarmTransitionKind> rows);
|
||||
|
||||
snap.Active.ShouldBeTrue();
|
||||
snap.Acknowledged.ShouldBeTrue();
|
||||
rows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
|
||||
}
|
||||
|
||||
/// <summary>#656 — the wider half of claim 1: a KNOWN but inactive prior (cleared before a gateway outage, then went
|
||||
/// active and acked during it) must also activate.</summary>
|
||||
[Fact]
|
||||
public void ActiveAcknowledged_on_an_inactive_known_prior_activates()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Clear)).Active.ShouldBeFalse();
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList<AlarmTransitionKind> rows);
|
||||
|
||||
snap.Active.ShouldBeTrue();
|
||||
snap.Acknowledged.ShouldBeTrue();
|
||||
rows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActiveAcknowledged_on_an_active_unacked_condition_writes_only_an_Acknowledged_row()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList<AlarmTransitionKind> rows);
|
||||
|
||||
snap.Active.ShouldBeTrue();
|
||||
snap.Acknowledged.ShouldBeTrue();
|
||||
rows.ShouldBe([AlarmTransitionKind.Acknowledge]);
|
||||
}
|
||||
|
||||
/// <summary>#656 related item 2 — a Galaxy reconnect replays an ActiveAcked entry the host already shows; that
|
||||
/// is no row. Control: the same entry with a different message is a different alarm and writes both rows.</summary>
|
||||
[Fact]
|
||||
public void ActiveAcknowledged_on_an_active_acked_condition_with_the_same_message_writes_no_row()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hi"));
|
||||
|
||||
var same = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hi"), out IReadOnlyList<AlarmTransitionKind> sameRows);
|
||||
sameRows.ShouldBeEmpty();
|
||||
same.Active.ShouldBeTrue();
|
||||
same.Acknowledged.ShouldBeTrue();
|
||||
|
||||
// Control: a different message is a different alarm.
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hihi"), out IReadOnlyList<AlarmTransitionKind> otherRows);
|
||||
otherRows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
|
||||
}
|
||||
|
||||
/// <summary>#656 claim 2 — a re-stated Retrigger (Galaxy snapshot replay, OpcUaClient first sight) on a condition
|
||||
/// already Active+unacked with the same message and severity writes no row; the node does not change.</summary>
|
||||
[Fact]
|
||||
public void A_restated_Retrigger_on_an_active_unacked_condition_with_same_message_and_severity_writes_no_row()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList<AlarmTransitionKind> first);
|
||||
first.ShouldBe([AlarmTransitionKind.Retrigger], "the first statement on an unknown condition is a real activation");
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
|
||||
|
||||
rows.ShouldBeEmpty();
|
||||
snap.Active.ShouldBeTrue();
|
||||
snap.Acknowledged.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Control (a): an UNFLAGGED Retrigger — a live Galaxy re-occurrence, a FOCAS key swap — with the same
|
||||
/// everything is a genuine re-occurrence and keeps its row. FOCAS/MTConnect/AbCip behaviour is unchanged.</summary>
|
||||
[Fact]
|
||||
public void An_unflagged_Retrigger_on_an_active_unacked_condition_still_writes_an_Activated_row()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger));
|
||||
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out IReadOnlyList<AlarmTransitionKind> rows);
|
||||
|
||||
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
|
||||
}
|
||||
|
||||
/// <summary>Control (b): a restated Retrigger whose SEVERITY changed (MTConnect Warning→Fault with a detail text
|
||||
/// keeps the message) is a new statement.</summary>
|
||||
[Fact]
|
||||
public void A_restated_Retrigger_with_a_different_severity_writes_an_Activated_row()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, AlarmSeverity.Medium, restated: true));
|
||||
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, AlarmSeverity.High, restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
|
||||
|
||||
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
|
||||
}
|
||||
|
||||
/// <summary>Control (b'): the exact-severity path (<see cref="AlarmEventArgs.OpcUaSeverity"/>) is compared too.</summary>
|
||||
[Fact]
|
||||
public void A_restated_Retrigger_with_a_different_exact_severity_writes_an_Activated_row()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true) with { OpcUaSeverity = 500 });
|
||||
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true) with { OpcUaSeverity = 800 },
|
||||
out IReadOnlyList<AlarmTransitionKind> rows);
|
||||
|
||||
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
|
||||
}
|
||||
|
||||
/// <summary>Control (c): a restated Retrigger with a different message is a different alarm.</summary>
|
||||
[Fact]
|
||||
public void A_restated_Retrigger_with_a_different_message_writes_an_Activated_row()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, msg: "a", restated: true));
|
||||
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, msg: "b", restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
|
||||
|
||||
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
|
||||
}
|
||||
|
||||
/// <summary>Control (d): a restated Retrigger on an Active+ACKED condition un-acks (upstream's ack state is
|
||||
/// authoritative), so the node changes and the row is written.</summary>
|
||||
[Fact]
|
||||
public void A_restated_Retrigger_on_an_active_acked_condition_unacks_and_writes_an_Activated_row()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger));
|
||||
sut.MarkAcknowledged("n1");
|
||||
|
||||
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
|
||||
|
||||
snap.Acknowledged.ShouldBeFalse();
|
||||
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
|
||||
}
|
||||
|
||||
/// <summary>#656 related item 3 — a restated Clear (Galaxy Inactive snapshot entry) on a condition that is already
|
||||
/// inactive writes no row; the unflagged control and a restated Clear of an ACTIVE condition both write one.</summary>
|
||||
[Fact]
|
||||
public void A_restated_Clear_on_an_inactive_condition_writes_no_row()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
|
||||
sut.Project("unknown", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList<AlarmTransitionKind> unknownRows);
|
||||
unknownRows.ShouldBeEmpty();
|
||||
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Clear));
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
|
||||
rows.ShouldBeEmpty();
|
||||
|
||||
// Controls: unflagged on inactive keeps its row (unchanged behaviour); restated on ACTIVE is a real clear.
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Clear), out IReadOnlyList<AlarmTransitionKind> unflagged);
|
||||
unflagged.ShouldBe([AlarmTransitionKind.Clear]);
|
||||
sut.Project("n2", Evt(AlarmTransitionKind.Raise));
|
||||
sut.Project("n2", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList<AlarmTransitionKind> active);
|
||||
active.ShouldBe([AlarmTransitionKind.Clear]);
|
||||
}
|
||||
|
||||
/// <summary>#656 — the existing per-kind rows for unflagged sources are exactly one row of the event's own kind
|
||||
/// (what the host wrote before, byte-identical for FOCAS/MTConnect/AbCip).</summary>
|
||||
[Theory]
|
||||
[InlineData(AlarmTransitionKind.Raise)]
|
||||
[InlineData(AlarmTransitionKind.Retrigger)]
|
||||
[InlineData(AlarmTransitionKind.Acknowledge)]
|
||||
[InlineData(AlarmTransitionKind.Clear)]
|
||||
[InlineData(AlarmTransitionKind.Unspecified)]
|
||||
public void An_unflagged_transition_writes_one_row_of_its_own_kind(AlarmTransitionKind kind)
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
|
||||
sut.Project("n1", Evt(kind), out IReadOnlyList<AlarmTransitionKind> rows);
|
||||
|
||||
rows.ShouldBe([kind]);
|
||||
}
|
||||
|
||||
private static AlarmEventArgs Evt(
|
||||
AlarmTransitionKind kind,
|
||||
AlarmSeverity sev = AlarmSeverity.High,
|
||||
string msg = "m")
|
||||
=> new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind);
|
||||
string msg = "m",
|
||||
bool restated = false)
|
||||
=> new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind, IsRestatement: restated);
|
||||
|
||||
private sealed class H : IAlarmSubscriptionHandle
|
||||
{
|
||||
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.TestKit;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
using LocalizedText = Opc.Ua.LocalizedText;
|
||||
using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using UaNodeId = Opc.Ua.NodeId;
|
||||
using Variant = Opc.Ua.Variant;
|
||||
using VariantCollection = Opc.Ua.VariantCollection;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #656 — native-alarm RE-STATEMENTS end to end: the REAL Galaxy driver (over the REAL
|
||||
/// <see cref="GatewayGalaxyAlarmFeed"/> reading a scripted gateway stream) and the REAL OpcUaClient driver raise
|
||||
/// into the REAL <see cref="DriverHostActor"/>, and the test reads what its <c>ForwardNativeAlarm</c> +
|
||||
/// <c>NativeAlarmProjector</c> turned them into: the Part 9 condition updates and the <c>/alerts</c> rows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every "no row" assertion is followed by a transition that MUST write one — an absence check on its own passes
|
||||
/// against a host that never publishes anything.
|
||||
/// </remarks>
|
||||
public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase
|
||||
{
|
||||
private static readonly NodeId TestNode = NodeId.Parse("alarm-restatement-e2e");
|
||||
private static readonly RevisionHash Rev = RevisionHash.Parse(new string('a', 64));
|
||||
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan Quiet = TimeSpan.FromMilliseconds(600);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
// Galaxy
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
private const string GalaxyDriverId = "drv-galaxy";
|
||||
private const string GalaxyAlarmRef = "Tank01.Level.HiHi";
|
||||
private const string GalaxyRawPath = "Plant/Galaxy/gw/tank01_hihi";
|
||||
|
||||
/// <summary>
|
||||
/// #656 claim 1 — a cold host receives an ActiveAcked snapshot entry (an alarm active AND acknowledged in
|
||||
/// AVEVA before this process started). Pre-fix the feed sent Acknowledge, which the projector applied to its
|
||||
/// inactive default: the condition was INACTIVE+acked — invisible to clients — with an "Acknowledged" row for
|
||||
/// an alarm the server showed as inactive. Now: ONE Active+acked update, "Activated" (at the original raise
|
||||
/// time, no operator comment) then "Acknowledged" (carrying the comment).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Galaxy_cold_host_ActiveAcked_snapshot_projects_active_and_acked_with_both_rows()
|
||||
{
|
||||
var rig = await GalaxyRig.StartAsync(this);
|
||||
try
|
||||
{
|
||||
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
|
||||
var ackedAt = raisedAt.AddMinutes(2);
|
||||
rig.Send(Snapshot(Proto.AlarmConditionState.ActiveAcked, raisedAt, ackedAt, "alice", "investigating"));
|
||||
|
||||
var update = NextAlarmUpdate(rig.Publish);
|
||||
update.AlarmNodeId.ShouldBe(GalaxyRawPath);
|
||||
update.State.Active.ShouldBeTrue("an acked active alarm must project ACTIVE, not keep the cold default");
|
||||
update.State.Acknowledged.ShouldBeTrue();
|
||||
|
||||
var activated = rig.Alerts.ExpectMsg<AlarmTransitionEvent>(Wait);
|
||||
activated.TransitionKind.ShouldBe("Activated");
|
||||
activated.Comment.ShouldBeNull("the operator comment belongs to the acknowledgement");
|
||||
activated.TimestampUtc.ShouldBe(raisedAt);
|
||||
var acked = rig.Alerts.ExpectMsg<AlarmTransitionEvent>(Wait);
|
||||
acked.TransitionKind.ShouldBe("Acknowledged");
|
||||
acked.Comment.ShouldBe("investigating");
|
||||
rig.Alerts.ExpectNoMsg(Quiet);
|
||||
|
||||
// Positive control: a live Clear MUST write its row — and the NEXT condition update is the clear, so no
|
||||
// unacked intermediate update sat between the snapshot's projection and it.
|
||||
rig.Send(Live(Proto.AlarmTransitionKind.Clear, ackedAt.AddMinutes(1)));
|
||||
var cleared = NextAlarmUpdate(rig.Publish);
|
||||
cleared.State.Active.ShouldBeFalse();
|
||||
cleared.State.Acknowledged.ShouldBeTrue();
|
||||
rig.Alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await rig.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #656 claim 2 — every gateway reconnect replays the active-alarm snapshot. Pre-fix an unacked active entry
|
||||
/// (a Retrigger) wrote a second "Activated" row on EVERY reconnect, for a condition whose node did not change
|
||||
/// (so clients saw no event: history and the node disagreed). Likewise an Inactive entry wrote a duplicate
|
||||
/// "Cleared". Now a replay re-projects the condition but writes no row; a live transition still does.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Galaxy_reconnect_snapshot_replay_writes_no_duplicate_rows()
|
||||
{
|
||||
var rig = await GalaxyRig.StartAsync(this);
|
||||
try
|
||||
{
|
||||
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
|
||||
rig.Send(Snapshot(Proto.AlarmConditionState.Active, raisedAt, raisedAt));
|
||||
NextAlarmUpdate(rig.Publish).State.Active.ShouldBeTrue();
|
||||
rig.Alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated",
|
||||
"the first statement on a cold host is a real activation");
|
||||
|
||||
// The gateway stream faults; the feed reopens and the gateway replays the same snapshot.
|
||||
rig.Fault();
|
||||
rig.Send(Snapshot(Proto.AlarmConditionState.Active, raisedAt, raisedAt));
|
||||
var replayed = NextAlarmUpdate(rig.Publish);
|
||||
replayed.State.Active.ShouldBeTrue();
|
||||
replayed.State.Acknowledged.ShouldBeFalse();
|
||||
rig.Alerts.ExpectNoMsg(Quiet);
|
||||
|
||||
// Positive control: a live Clear MUST write its row.
|
||||
rig.Send(Live(Proto.AlarmTransitionKind.Clear, raisedAt.AddMinutes(1)));
|
||||
NextAlarmUpdate(rig.Publish).State.Active.ShouldBeFalse();
|
||||
rig.Alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
|
||||
// Reconnect again; the snapshot now carries the alarm as Inactive — a re-stated Clear of an inactive
|
||||
// condition, no row.
|
||||
rig.Fault();
|
||||
rig.Send(Snapshot(Proto.AlarmConditionState.Inactive, raisedAt, raisedAt.AddMinutes(1)));
|
||||
NextAlarmUpdate(rig.Publish).State.Active.ShouldBeFalse();
|
||||
rig.Alerts.ExpectNoMsg(Quiet);
|
||||
|
||||
// Positive control: a LIVE Retrigger (a genuine MXAccess re-occurrence) is never a re-statement.
|
||||
rig.Send(Live(Proto.AlarmTransitionKind.Retrigger, raisedAt.AddMinutes(3)));
|
||||
NextAlarmUpdate(rig.Publish).State.Active.ShouldBeTrue();
|
||||
rig.Alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await rig.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static Proto.AlarmFeedMessage Snapshot(
|
||||
Proto.AlarmConditionState state, DateTime raisedAt, DateTime lastTransition, string user = "", string comment = "")
|
||||
=> new()
|
||||
{
|
||||
ActiveAlarm = new Proto.ActiveAlarmSnapshot
|
||||
{
|
||||
AlarmFullReference = GalaxyAlarmRef,
|
||||
SourceObjectReference = "Tank01",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
Severity = 750,
|
||||
CurrentState = state,
|
||||
Category = "Process",
|
||||
Description = "Tank 01 high-high level",
|
||||
OriginalRaiseTimestamp = Timestamp.FromDateTime(raisedAt),
|
||||
LastTransitionTimestamp = Timestamp.FromDateTime(lastTransition),
|
||||
OperatorUser = user,
|
||||
OperatorComment = comment,
|
||||
},
|
||||
};
|
||||
|
||||
private static Proto.AlarmFeedMessage Live(Proto.AlarmTransitionKind kind, DateTime at)
|
||||
=> new()
|
||||
{
|
||||
Transition = new Proto.OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = GalaxyAlarmRef,
|
||||
SourceObjectReference = "Tank01",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind = kind,
|
||||
Severity = 750,
|
||||
Category = "Process",
|
||||
Description = "Tank 01 high-high level",
|
||||
TransitionTimestamp = Timestamp.FromDateTime(at),
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>The real Galaxy driver over the real alarm feed, reading a scripted gateway stream.</summary>
|
||||
private sealed class GalaxyRig : IAsyncDisposable
|
||||
{
|
||||
private readonly Channel<object> _stream = Channel.CreateUnbounded<object>();
|
||||
private GalaxyDriver? _driver;
|
||||
|
||||
public TestProbe Publish { get; private set; } = null!;
|
||||
public TestProbe Alerts { get; private set; } = null!;
|
||||
|
||||
public static async Task<GalaxyRig> StartAsync(NativeAlarmRestatementEndToEndTests t)
|
||||
{
|
||||
var rig = new GalaxyRig();
|
||||
var db = NewInMemoryDbFactory();
|
||||
var blob = BuildArtifact(GalaxyDriverId, DriverTypeNames.Galaxy, "Galaxy", "gw", "tank01_hihi",
|
||||
new { attributeRef = GalaxyAlarmRef, alarm = new { alarmType = "AlarmCondition", severity = 800 } });
|
||||
rig.Alerts = t.SubscribeAlerts();
|
||||
var (host, publish, _) = t.SpawnHostAndApply(db, Seed(db, blob, Rev));
|
||||
rig.Publish = publish;
|
||||
|
||||
var rawTags = RawTagsFor(blob, GalaxyDriverId);
|
||||
rawTags.Select(r => r.RawPath).ShouldBe([GalaxyRawPath]);
|
||||
var feed = new GatewayGalaxyAlarmFeed(rig.OpenStream, clientName: "Restatement656",
|
||||
reconnectDelay: TimeSpan.FromMilliseconds(20));
|
||||
var options = new GalaxyDriverOptions(
|
||||
new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"),
|
||||
new GalaxyMxAccessOptions("Restatement656"),
|
||||
new GalaxyRepositoryOptions(),
|
||||
new GalaxyReconnectOptions()) { RawTags = rawTags };
|
||||
rig._driver = new GalaxyDriver(GalaxyDriverId, options, hierarchySource: null, alarmFeed: feed);
|
||||
rig._driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(GalaxyDriverId, e));
|
||||
#pragma warning disable OTOPCUA0001 // the routing is under test, not resilience wrapping
|
||||
await rig._driver.SubscribeAlarmsAsync([GalaxyRawPath], CancellationToken.None);
|
||||
#pragma warning restore OTOPCUA0001
|
||||
return rig;
|
||||
}
|
||||
|
||||
public void Send(Proto.AlarmFeedMessage message) => _stream.Writer.TryWrite(message).ShouldBeTrue();
|
||||
|
||||
/// <summary>Fault the current gateway stream; the feed reopens it (the next open reads on).</summary>
|
||||
public void Fault() => _stream.Writer.TryWrite(new IOException("synthetic gateway stream fault")).ShouldBeTrue();
|
||||
|
||||
private async IAsyncEnumerable<Proto.AlarmFeedMessage> OpenStream(
|
||||
Proto.StreamAlarmsRequest request, [EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var item = await _stream.Reader.ReadAsync(ct);
|
||||
if (item is Exception ex) throw ex;
|
||||
yield return (Proto.AlarmFeedMessage)item;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_driver is not null) await _driver.ShutdownAsync(CancellationToken.None);
|
||||
_driver?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
// OpcUaClient
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
private const string OpcDriverId = "drv-opcua";
|
||||
private const string OpcRawPath = "Plant/Opc/plc/trip1";
|
||||
private const string UpstreamSource = "ns=3;s=Alarms.Trip1";
|
||||
|
||||
/// <summary>
|
||||
/// #656 claim 2 (OpcUaClient) — a deploy that changes the driver's config RESPAWNS it, and the fresh instance's
|
||||
/// ConditionRefresh re-reads every retained condition with no record of what the host was told. Its first
|
||||
/// sight of an unacked active alarm is a Retrigger (#647: upstream's unack is authoritative); pre-fix that
|
||||
/// wrote a second "Activated" row for a condition already showing exactly that. Now it is a flagged
|
||||
/// re-statement: the condition is re-projected (still unacked) and no row is written.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task OpcUaClient_respawn_restating_an_unacked_active_alarm_writes_no_second_Activated_row()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var blob = BuildOpcUaArtifact();
|
||||
var alerts = SubscribeAlerts();
|
||||
var (host, publish, coordinator) = SpawnHostAndApply(db, Seed(db, blob, Rev));
|
||||
var handle = new Handle();
|
||||
|
||||
using (var a = NewOpcUaDriver(host, "a"))
|
||||
{
|
||||
a.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle);
|
||||
var raised = NextAlarmUpdate(publish);
|
||||
raised.AlarmNodeId.ShouldBe(OpcRawPath);
|
||||
raised.State.Active.ShouldBeTrue();
|
||||
raised.State.Acknowledged.ShouldBeFalse();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
}
|
||||
|
||||
// A config-changing deploy: the host keeps the still-deployed condition's state, the driver is respawned.
|
||||
var revB = RevisionHash.Parse(new string('b', 64));
|
||||
host.Tell(new DispatchDeployment(Seed(db, blob, revB), revB, CorrelationId.NewId()));
|
||||
coordinator.FishForMessage<ApplyAck>(x => x.Outcome == ApplyAckOutcome.Applied, Wait);
|
||||
publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, Wait);
|
||||
|
||||
using var b = NewOpcUaDriver(host, "b");
|
||||
b.RouteAlarmEvent(ConditionFields(active: true, acked: false), SourceIndex, handle); // ConditionRefresh copy
|
||||
var restated = NextAlarmUpdate(publish);
|
||||
restated.State.Active.ShouldBeTrue();
|
||||
restated.State.Acknowledged.ShouldBeFalse("upstream says unacknowledged");
|
||||
alerts.ExpectNoMsg(Quiet);
|
||||
|
||||
// Positive control: the upstream operator acknowledges — that transition MUST write its row.
|
||||
b.RouteAlarmEvent(ConditionFields(active: true, acked: true), SourceIndex, handle);
|
||||
var acked = NextAlarmUpdate(publish);
|
||||
acked.State.Acknowledged.ShouldBeTrue();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Acknowledged");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #656 related item 1 (OpcUaClient) — an upstream condition first seen Active+acked used to go out as
|
||||
/// Raise-then-Acknowledge: TWO condition updates, the first Active+UNacked (a client-visible un-ack window).
|
||||
/// Now it is ONE ActiveAcknowledged: the first condition update is already acked, with "Activated" then
|
||||
/// "Acknowledged" rows.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OpcUaClient_first_sight_of_an_acked_active_alarm_is_one_active_and_acked_update()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var alerts = SubscribeAlerts();
|
||||
var (host, publish, _) = SpawnHostAndApply(db, Seed(db, BuildOpcUaArtifact(), Rev));
|
||||
var handle = new Handle();
|
||||
using var drv = NewOpcUaDriver(host, "cold");
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(active: true, acked: true), SourceIndex, handle);
|
||||
var first = NextAlarmUpdate(publish);
|
||||
first.State.Active.ShouldBeTrue();
|
||||
first.State.Acknowledged.ShouldBeTrue("no Active+unacked intermediate may reach clients");
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Activated");
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Acknowledged");
|
||||
alerts.ExpectNoMsg(Quiet);
|
||||
|
||||
// Positive control: the next update is the clear (not a leftover second update), and it writes its row.
|
||||
drv.RouteAlarmEvent(ConditionFields(active: false, acked: true), SourceIndex, handle);
|
||||
var cleared = NextAlarmUpdate(publish);
|
||||
cleared.State.Active.ShouldBeFalse();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(Wait).TransitionKind.ShouldBe("Cleared");
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> SourceIndex = new() { [UpstreamSource] = OpcRawPath };
|
||||
|
||||
private static OpcUaClientDriver NewOpcUaDriver(IActorRef host, string suffix)
|
||||
{
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), $"{OpcDriverId}-{suffix}");
|
||||
drv.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(OpcDriverId, e));
|
||||
return drv;
|
||||
}
|
||||
|
||||
/// <summary>An upstream A&C condition event, in the driver's select-clause order.</summary>
|
||||
private static VariantCollection ConditionFields(bool active, bool acked) =>
|
||||
[
|
||||
new Variant(new byte[] { 1 }), // EventId
|
||||
new Variant(new UaNodeId("TripAlarmType", 3)), // EventType
|
||||
new Variant(UaNodeId.Parse(UpstreamSource)), // SourceNode
|
||||
new Variant(new LocalizedText("tripped")), // Message
|
||||
new Variant((ushort)700), // Severity
|
||||
new Variant(DateTime.UtcNow), // Time
|
||||
new Variant(UaNodeId.Parse(UpstreamSource + ".Cond")), // ConditionId
|
||||
new Variant(active), // ActiveState/Id
|
||||
new Variant(acked), // AckedState/Id
|
||||
Variant.Null, // BranchId
|
||||
];
|
||||
|
||||
private static byte[] BuildOpcUaArtifact() => BuildArtifact(OpcDriverId, DriverTypeNames.OpcUaClient, "Opc", "plc", "trip1",
|
||||
new { nodeId = UpstreamSource, alarm = new { alarmType = "AlarmCondition", severity = 700 } });
|
||||
|
||||
private sealed class Handle : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "e2e";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
// Shared host harness (as FocasNativeAlarmEndToEndTests)
|
||||
// ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>The next condition update the host sent to the publish actor (skipping anything else).</summary>
|
||||
private static OpcUaPublishActor.AlarmStateUpdate NextAlarmUpdate(TestProbe publish) =>
|
||||
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, Wait);
|
||||
|
||||
private TestProbe SubscribeAlerts()
|
||||
{
|
||||
var alerts = CreateTestProbe();
|
||||
DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, alerts.Ref), alerts.Ref);
|
||||
alerts.ExpectMsg<SubscribeAck>(Wait);
|
||||
return alerts;
|
||||
}
|
||||
|
||||
private static List<RawTagEntry> RawTagsFor(byte[] blob, string driverInstanceId)
|
||||
{
|
||||
using var artifact = DeploymentArtifact.Open(blob);
|
||||
var spec = artifact.ParseDriverInstances().Single(s => s.DriverInstanceId == driverInstanceId);
|
||||
using var doc = JsonDocument.Parse(spec.DriverConfig);
|
||||
return JsonSerializer.Deserialize<List<RawTagEntry>>(
|
||||
doc.RootElement.GetProperty("RawTags").GetRawText(),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
}
|
||||
|
||||
private static byte[] BuildArtifact(
|
||||
string driverId, string driverType, string driverName, string deviceName, string tagName, object tagConfig) =>
|
||||
JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[]
|
||||
{
|
||||
// Enabled=false: the host must not spawn a real wire driver — the test drives its own instance.
|
||||
new { DriverInstanceId = driverId, RawFolderId = "rf-plant", Name = driverName, DriverType = driverType, DriverConfig = "{}", ClusterId = "c1", Enabled = false },
|
||||
},
|
||||
Devices = new[]
|
||||
{
|
||||
new { DeviceId = "dev-1", DriverInstanceId = driverId, Name = deviceName, DeviceConfig = "{}" },
|
||||
},
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "tag-alarm",
|
||||
DeviceId = "dev-1",
|
||||
TagGroupId = (string?)null,
|
||||
Name = tagName,
|
||||
DataType = "Boolean",
|
||||
AccessLevel = 0,
|
||||
TagConfig = JsonSerializer.Serialize(tagConfig),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
private static DeploymentId Seed(IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] blob, RevisionHash rev)
|
||||
{
|
||||
var id = DeploymentId.NewId();
|
||||
using var ctx = db.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = id.Value,
|
||||
RevisionHash = rev.Value,
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = blob,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return id;
|
||||
}
|
||||
|
||||
private (IActorRef Actor, TestProbe Publish, TestProbe Coordinator) SpawnHostAndApply(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||
{
|
||||
var coordinator = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
db, TestNode, coordinator.Ref,
|
||||
localRoles: new HashSet<string> { "driver" },
|
||||
dependencyMux: CreateTestProbe().Ref,
|
||||
opcUaPublishActor: publish.Ref,
|
||||
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
||||
virtualTagHostOverride: CreateTestProbe().Ref));
|
||||
|
||||
actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId()));
|
||||
coordinator.ExpectMsg<ApplyAck>(Wait).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Wait);
|
||||
return (actor, publish, coordinator);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,10 @@
|
||||
<!-- Gitea #647: the real FOCAS driver, so the native-alarm end-to-end test proves a CNC alarm reaches the
|
||||
host's Part 9 condition (ForwardNativeAlarm → NativeAlarmProjector), not merely that it was raised. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<!-- Gitea #656: the real Galaxy (over a scripted gateway alarm stream) and OpcUaClient drivers, so the
|
||||
re-statement end-to-end tests prove what reaches the host's /alerts rows and Part 9 conditions. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user