feat(scripted-alarms): fire Part 9 condition events on transition (T16)
This commit is contained in:
@@ -141,14 +141,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
condition.Message.Value = new LocalizedText(state.Message);
|
condition.Message.Value = new LocalizedText(state.Message);
|
||||||
|
|
||||||
// Part 9: retain the condition while it is active OR unacknowledged so a client's
|
// 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
|
// ConditionRefresh replays it. The event firing below also depends on this Retain being
|
||||||
// it correct for the projection.
|
// 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.Retain.Value = state.Active || !state.Acknowledged;
|
||||||
condition.Time.Value = sourceTimestampUtc;
|
condition.Time.Value = sourceTimestampUtc;
|
||||||
condition.ReceiveTime.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.
|
// T16 — fire a real Part 9 condition event for THIS engine-driven transition. The
|
||||||
condition.ClearChangeMasks(SystemContext, includeChildren: true);
|
// 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;
|
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>
|
/// <summary>
|
||||||
/// Materialise a real OPC UA Part 9 <see cref="AlarmConditionState"/> node under its equipment
|
/// 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
|
/// folder so clients can browse it as a proper condition (and subscribe to its events). The node
|
||||||
/// events). The node id is the alarm node id (the ScriptedAlarmId) so subsequent
|
/// id is the alarm node id (the ScriptedAlarmId) so subsequent
|
||||||
/// <see cref="WriteAlarmCondition"/> calls — which target that same id — update this node.
|
/// <see cref="WriteAlarmCondition"/> calls — which target that same id — update this node.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This is the T14 production replacement for the <c>bool[2]</c> placeholder: it creates
|
/// This is the T14 production replacement for the <c>bool[2]</c> placeholder: it creates
|
||||||
|
|||||||
@@ -264,6 +264,96 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
|||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>T16 — each engine-driven <see cref="OtOpcUaNodeManager.WriteAlarmCondition"/> on a
|
||||||
|
/// materialised condition fires a real Part 9 condition event, stamping a FRESH per-event
|
||||||
|
/// <c>EventId</c> (GUID bytes). Part 9 requires a unique EventId per event so inbound
|
||||||
|
/// Acknowledge/Confirm (T17) can correlate back to the exact event being acked.
|
||||||
|
/// <para>
|
||||||
|
/// We assert EventId-freshness (non-null + changed from its materialise-time value, and a second
|
||||||
|
/// write yields a DIFFERENT EventId) plus no-throw. The node manager exposes no in-process hook to
|
||||||
|
/// observe <c>ReportEvent</c> delivery without standing up a full client subscription + monitored
|
||||||
|
/// item, so actual event DELIVERY to a subscribing client is proven live in T19 (Client.CLI) rather
|
||||||
|
/// than faked here — see the comment below.
|
||||||
|
/// </para></summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAlarmCondition_fires_event_with_fresh_EventId_per_transition()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var sink = new SdkAddressSpaceSink(nm);
|
||||||
|
|
||||||
|
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt");
|
||||||
|
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition("alm-evt");
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// EventId at materialise time (Create stamps an initial one). Capture a COPY — the live byte[]
|
||||||
|
// reference is reused, so we compare snapshots, not the same array instance.
|
||||||
|
var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone();
|
||||||
|
|
||||||
|
// First engine-driven transition → fires an event with a fresh EventId.
|
||||||
|
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
||||||
|
|
||||||
|
var firstEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||||
|
firstEventId.ShouldNotBeNull();
|
||||||
|
firstEventId!.Length.ShouldBe(16); // GUID bytes
|
||||||
|
// Changed from the materialise-time value (a real event fired, not the create-time stamp).
|
||||||
|
if (materialiseEventId is not null)
|
||||||
|
{
|
||||||
|
firstEventId.ShouldNotBe(materialiseEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous).
|
||||||
|
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow);
|
||||||
|
|
||||||
|
var secondEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||||
|
secondEventId.ShouldNotBeNull();
|
||||||
|
secondEventId!.ShouldNotBe(firstEventId);
|
||||||
|
|
||||||
|
// NOTE: event DELIVERY (a subscribing client actually receiving these ActiveState/AckedState
|
||||||
|
// transitions on the equipment folder) is an integration concern proven LIVE in T19 via the
|
||||||
|
// Client.CLI alarm subscription — not asserted here, and deliberately NOT faked. This unit-level
|
||||||
|
// test proves the firing path is exercised (no throw) and the Part 9 EventId-freshness invariant
|
||||||
|
// that T17's ack routing depends on.
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>T16 — firing an event must not break the state projection even when ReportEvent could
|
||||||
|
/// fail. We can't easily force ReportEvent to throw in-process, but we CAN prove the projection +
|
||||||
|
/// firing path is exception-safe end-to-end across repeated transitions and that the condition's
|
||||||
|
/// mandatory state stays correct after firing.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAlarmCondition_firing_does_not_disturb_projected_state()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var sink = new SdkAddressSpaceSink(nm);
|
||||||
|
|
||||||
|
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2");
|
||||||
|
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||||
|
|
||||||
|
var condition = nm.TryGetAlarmCondition("alm-evt2");
|
||||||
|
condition.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Drive several transitions; each fires an event AND projects state. State must survive firing.
|
||||||
|
Should.NotThrow(() =>
|
||||||
|
{
|
||||||
|
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||||
|
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow);
|
||||||
|
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Final projected state is intact after the last firing.
|
||||||
|
condition!.ActiveState.Id.Value.ShouldBeFalse();
|
||||||
|
condition.AckedState.Id.Value.ShouldBeTrue();
|
||||||
|
condition.Message.Value.Text.ShouldBe("cleared");
|
||||||
|
condition.Retain.Value.ShouldBeFalse(); // inactive && acked ⇒ no retain
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each call
|
/// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each call
|
||||||
/// site only specifies the fields it cares about.</summary>
|
/// site only specifies the fields it cares about.</summary>
|
||||||
private static AlarmConditionSnapshot Snapshot(
|
private static AlarmConditionSnapshot Snapshot(
|
||||||
|
|||||||
Reference in New Issue
Block a user