feat(scripted-alarms): fire Part 9 condition events on transition (T16)

This commit is contained in:
Joseph Doherty
2026-06-10 19:50:09 -04:00
parent ab5d0752d8
commit 295bb55dc6
2 changed files with 163 additions and 7 deletions
@@ -141,14 +141,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
condition.Message.Value = new LocalizedText(state.Message);
// Part 9: retain the condition while it is active OR unacknowledged so a client's
// ConditionRefresh replays it. T16's event firing will also drive Retain; here we keep
// it correct for the projection.
// ConditionRefresh replays it. The event firing below also depends on this Retain being
// correct (a non-retained inactive+acked condition still fires its transition event, but
// won't be replayed on a later ConditionRefresh).
condition.Retain.Value = state.Active || !state.Acknowledged;
condition.Time.Value = sourceTimestampUtc;
condition.ReceiveTime.Value = sourceTimestampUtc;
// NO ReportEvent here — T16 owns event firing. ClearChangeMasks just notifies any
// attribute (not event) subscribers watching the condition's children directly.
condition.ClearChangeMasks(SystemContext, includeChildren: true);
// T16 — fire a real Part 9 condition event for THIS engine-driven transition. The
// ScriptedAlarmHostActor only calls WriteAlarmCondition on a REAL state transition
// (Emission != None/Suppressed), so every call corresponds to exactly one transition →
// fire exactly one event. ReportConditionEvent stamps a fresh EventId, ClearChangeMasks,
// and ReportEvent — all still under this lock.
ReportConditionEvent(condition, sourceTimestampUtc);
return;
}
@@ -168,10 +173,71 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
/// materialised <see cref="AlarmConditionState"/>. The caller MUST already hold <c>Lock</c> and
/// have applied the new state via the <c>Set*</c> projection — this stamps a fresh per-event
/// <c>EventId</c>, <c>ClearChangeMasks</c>, then <c>ReportEvent</c> with an
/// <see cref="InstanceStateSnapshot"/> (a frozen copy of the condition's children at fire time,
/// so a subscribing client sees the values at this instant even if the live node mutates after).
/// <para>
/// A fresh <c>EventId</c> per event is a Part 9 requirement: inbound Acknowledge / Confirm /
/// AddComment calls are correlated back to a specific event by this id (the SDK matches it via
/// <c>GetEventByEventId</c> / <c>GetBranch</c>), so T17's ack routing relies on it being unique
/// per emission. We use the main branch only (<c>BranchId == NodeId.Null</c>, set at
/// materialise) — no branch creation here.
/// </para>
/// <para>
/// <b>Double-emit note (for T17).</b> This helper fires ONLY for ENGINE-DRIVEN (outbound)
/// transitions routed through <see cref="WriteAlarmCondition"/>. T17's inbound
/// Acknowledge/Confirm will go through the SDK's own <c>OnAcknowledgeCalled</c>, which already
/// auto-fires a condition event (<c>ReportStateChange</c>) on a successful ack. These don't
/// overlap today (T17 isn't built), but once it lands an engine-driven re-projection of an ack
/// the SDK has ALREADY auto-emitted could double-emit the same logical transition — to be
/// reconciled in T17 (either suppress the SDK auto-fire, or skip the engine re-projection for
/// transitions that originated from an inbound method call).
/// </para>
/// </summary>
/// <param name="alarm">The materialised condition whose new state has already been projected; must be non-null.</param>
/// <param name="ts">The source/receive timestamp (UTC) for this event.</param>
private void ReportConditionEvent(AlarmConditionState alarm, DateTime ts)
{
// Fresh GUID-bytes EventId per event — mandatory for Part 9 ack correlation (T17 relies on it).
alarm.EventId.Value = Guid.NewGuid().ToByteArray();
// Time/ReceiveTime/Message/Severity were set by the WriteAlarmCondition projection above; restamp
// Time/ReceiveTime here so this event carries this transition's instant even if the caller's
// projection ordering changes later.
alarm.Time.Value = ts;
alarm.ReceiveTime.Value = ts;
// Snapshot the children, then notify subscribers. ClearChangeMasks must precede the snapshot so
// the InstanceStateSnapshot captures the just-projected values.
alarm.ClearChangeMasks(SystemContext, includeChildren: true);
try
{
// InstanceStateSnapshot is the IFilterTarget — a frozen copy of the condition's fields at fire
// time. ReportEvent walks inverse notifier references up to the root-notifier folder (promoted
// in MaterialiseAlarmCondition), whose OnReportEvent hands off to Server.ReportEvent → the
// event reaches subscribed monitored items.
var snapshot = new InstanceStateSnapshot();
snapshot.Initialize(SystemContext, alarm);
alarm.ReportEvent(SystemContext, snapshot);
}
catch (Exception)
{
// A failed event report must NOT break the state projection or the calling actor: the node's
// state has already been applied + ClearChangeMasks'd, so attribute subscribers still see the
// change; only the event delivery is lost. There is no logger on this CustomNodeManager2
// (the SDK base class carries none), so swallow rather than propagate. T19's live Client.CLI
// run is the integration proof that the happy path delivers.
}
}
/// <summary>
/// Materialise a real OPC UA Part 9 <see cref="AlarmConditionState"/> node under its equipment
/// folder so clients can browse it as a proper condition (and, once T16 lands, subscribe to its
/// events). The node id is the alarm node id (the ScriptedAlarmId) so subsequent
/// folder so clients can browse it as a proper condition (and subscribe to its events). The node
/// id is the alarm node id (the ScriptedAlarmId) so subsequent
/// <see cref="WriteAlarmCondition"/> calls — which target that same id — update this node.
/// <para>
/// This is the T14 production replacement for the <c>bool[2]</c> placeholder: it creates