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(