diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index cb2dbfa1..b8fed3f0 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -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
}
}
+ ///
+ /// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
+ /// materialised . The caller MUST already hold Lock and
+ /// have applied the new state via the Set* projection — this stamps a fresh per-event
+ /// EventId, ClearChangeMasks, then ReportEvent with an
+ /// (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).
+ ///
+ /// A fresh EventId 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
+ /// GetEventByEventId / GetBranch), so T17's ack routing relies on it being unique
+ /// per emission. We use the main branch only (BranchId == NodeId.Null, set at
+ /// materialise) — no branch creation here.
+ ///
+ ///
+ /// Double-emit note (for T17). This helper fires ONLY for ENGINE-DRIVEN (outbound)
+ /// transitions routed through . T17's inbound
+ /// Acknowledge/Confirm will go through the SDK's own OnAcknowledgeCalled, which already
+ /// auto-fires a condition event (ReportStateChange) 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).
+ ///
+ ///
+ /// The materialised condition whose new state has already been projected; must be non-null.
+ /// The source/receive timestamp (UTC) for this event.
+ 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.
+ }
+ }
+
///
/// Materialise a real OPC UA Part 9 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
/// calls — which target that same id — update this node.
///
/// This is the T14 production replacement for the bool[2] placeholder: it creates
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs
index e02e5460..07215109 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs
@@ -264,6 +264,96 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
await host.DisposeAsync();
}
+ /// T16 — each engine-driven on a
+ /// materialised condition fires a real Part 9 condition event, stamping a FRESH per-event
+ /// EventId (GUID bytes). Part 9 requires a unique EventId per event so inbound
+ /// Acknowledge/Confirm (T17) can correlate back to the exact event being acked.
+ ///
+ /// 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 ReportEvent 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.
+ ///
+ [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();
+ }
+
+ /// 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.
+ [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();
+ }
+
/// Builds a test with sensible defaults so each call
/// site only specifies the fields it cares about.
private static AlarmConditionSnapshot Snapshot(