using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// /// Derives a full Part 9 from each native /// delta, tracking per-condition-NodeId prior state. Owned by the /// single-threaded DriverHostActor (no locking). Native alarms carry only a transition /// , not a full state machine, so this is the translation the /// scripted-alarm engine does internally. /// public sealed class NativeAlarmProjector { private readonly Dictionary _prior = new(StringComparer.Ordinal); /// Project an alarm transition onto the full condition snapshot for . /// The materialised condition node's id (the projection's state key). /// The native alarm transition. /// The full Part 9 condition snapshot to write to the node. public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e) => Project(nodeId, e, out IReadOnlyList _); /// /// Project an alarm transition, reporting whether it was a re-statement — a transition that writes no /// alerts row (see ). /// /// The materialised condition node's id (the projection's state key). /// The native alarm transition. /// True when produced no history row. /// The full Part 9 condition snapshot to write to the node. public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e, out bool restatement) { var snapshot = Project(nodeId, e, out IReadOnlyList historyKinds); restatement = historyKinds.Count == 0; return snapshot; } /// /// Project an alarm transition and report the alerts 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. /// /// Gitea #647 review — a 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 /// (or inactive → active) un-acknowledges. /// Gitea #656 — 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. /// Gitea #656 — a 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. /// /// /// The materialised condition node's id (the projection's state key). /// The native alarm transition. /// /// The alerts rows to write, in order — each a / /// ("Activated"), , or /// . Empty when the transition is a re-statement. /// /// The full Part 9 condition snapshot to write to the node. public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e, out IReadOnlyList 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 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. var sameMessage = prev.Active && string.Equals(prev.Message?.Trim(), e.Message.Trim(), StringComparison.Ordinal); bool active, acked; switch (e.Kind) { 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; // A message/severity-only update of an ALREADY-ACTIVE condition that the source flags as a re-statement // (AbCip ALMA: the governing limit changed but no new occurrence started, e.g. HH dropped back to H). // The node's text/severity move; active/acked are kept; NO row (an "Activated" row would be false). case AlarmTransitionKind.Unspecified when e.IsRestatement && prev.Active: (active, acked, historyKinds) = (true, prev.Acked, []); 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, // #626: a per-condition source quality (MTConnect Unavailable) when the driver reports one. Absent // (every pre-#626 source) ⇒ Good, as before — driver-wide comms loss stays the host's // out-of-band WriteAlarmQuality annotation (#477), not something the projector infers. StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good); } /// /// Gitea #641 review — record a client acknowledgement the node manager applied to /// and routed toward the driver. The projector otherwise never sees it, and a later Clear would project the /// stale unacknowledged prior. An untracked condition already defaults to acknowledged. /// /// The acknowledged condition node id. public void MarkAcknowledged(string nodeId) { if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = p with { Acked = true }; } /// Clears all tracked per-node state. public void Clear() => _prior.Clear(); /// /// Gitea #641 — drop the tracked state of every condition NOT in , keeping /// the rest. Called on every apply instead of : a condition that is still deployed keeps /// its state across the deploy (the node manager carries the node's state across a rebuild too), so the /// next Acknowledge does not project Active = false onto an active alarm and the next Clear does not /// project Acked = true onto an unacknowledged one. A removed condition is forgotten, so a later /// re-add starts from the default. /// /// The condition node ids the new composition still materialises. public void RetainOnly(IReadOnlySet deployedNodeIds) { ArgumentNullException.ThrowIfNull(deployedNodeIds); foreach (var nodeId in _prior.Keys.Where(k => !deployedNodeIds.Contains(k)).ToList()) _prior.Remove(nodeId); } private static ushort MapSeverity(AlarmSeverity s) => s switch { AlarmSeverity.Low => 200, AlarmSeverity.Medium => 500, AlarmSeverity.High => 700, AlarmSeverity.Critical => 900, _ => 500, }; }