diff --git a/CLAUDE.md b/CLAUDE.md index a195b856..3d65298b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1107,11 +1107,14 @@ condition event — the old "SDK auto-fires its own event (E2)" comments were wr wraps every method's SDK handler (Ack/Confirm/AddComment/Enable/Disable/OneShotShelve/TimedShelve/Unshelve) and, on Good, reports once via `ReportConditionEvent` — **under `Lock`**, so the engine's/driver's re-projection (routed by a fire-and-forget Tell *before* the SDK applies state) waits and then hits the delta-gate with no -delta. Things to keep: the SDK's shelve handlers never apply shelving, so the wrapper does; a **native** -condition's Confirmed and Shelving are method-owned (`WriteAlarmCondition` neither projects nor compares them — -`NativeAlarmProjector`'s constants would otherwise re-report every ack and undo every shelve); `MaxTimeShelved` -is set to `int.MaxValue` ms at materialise because `Create()` left it 0 and every TimedShelve failed. Pinned -over the wire by `ConditionMethodEventTests` (exactly-once after a sentinel, folder + `i=2253`). +delta. Things to keep: the SDK's shelve handlers never apply shelving, so the wrapper does; only a real +before/after change is reported (a re-ack is silent and restores the Confirmed/Retain the SDK flips); +`MaxTimeShelved` is set to `int.MaxValue` ms at materialise because `Create()` left it 0 and every TimedShelve +failed. ⚠️ **Native alarms support Acknowledge and AddComment only** — Confirm, the shelve methods and +Enable/Disable return `BadNotSupported` (no upstream path, no pair replication, and `NativeAlarmProjector`'s +Confirmed/Unshelved constants own those fields); a native ack re-asserts `ConfirmedState = true` because the +SDK's ack unconfirms any confirm-capable condition and ScadaBridge maps that field. Pinned over the wire by +`ConditionMethodEventTests` (exactly-once after a sentinel, folder + `i=2253`). ## Historian / HistoryRead diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index abcb1948..6504c719 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -314,9 +314,30 @@ from the scripted `AlarmCommandRouter`: OPC UA client (mirrors the Galaxy write-outcome limitation; the local `AlarmConditionState` SDK update already committed at step 1). -Only the **Acknowledge** is routed to the driver. `Confirm` / `AddComment` -/ `Shelve` operations on a native condition stay on the scripted -`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only). +Only the **Acknowledge** is routed to the driver. **A native condition +supports Acknowledge and AddComment only** (Gitea #587 review): `Confirm`, +`OneShotShelve` / `TimedShelve` / `Unshelve` and `Enable` / `Disable` return +`BadNotSupported` and report no event. There is no upstream path for them and +no pair replication of a local state, and the driver projection +(`NativeAlarmProjector`: always `Confirmed`, `Unshelved`) would silently undo +one at the next transition. `AddComment` stays on the scripted +`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only) and is +applied locally. + +A native condition is **never Unconfirmed**: it starts Confirmed, and because +the SDK's own Acknowledge sets `ConfirmedState = false` on any condition type +that supports Confirm, the node manager re-asserts `Confirmed = true` (and the +projection's Retain rule, `Active || !Acked`) after the SDK handler, before the +one ack event. ScadaBridge maps `ConfirmedState` into its alarm state, so this +is client-visible. + +> **Secondary-node ack flap.** On a node that is not the redundancy Primary, +> an OPC UA Acknowledge is applied to the local condition and reported (one +> event, Acked), but `DriverHostActor`'s Primary gate drops the route upstream. +> The driver never acknowledges, so its next transition for that alarm is +> projected with `Acked = false` and the node reverts — with its own event. A +> client acking against the Secondary sees acked → unacked. Ack against the +> Primary (the higher ServiceLevel) for the ack to stick. > **Best-effort during reconnect.** If a native ack arrives while the owning > driver is `Connecting`/`Reconnecting` (or on a non-Primary node), it is @@ -399,19 +420,22 @@ reports nothing. and suppresses. The whole method call runs under the node-manager lock, so a fast re-projection cannot slip in between the SDK applying the state and the report. -- **Native conditions: Confirmed and Shelving are method-owned.** - `NativeAlarmProjector` models neither (it always says `Confirmed = true`, - `Unshelved`), while the SDK's Acknowledge sets `Confirmed = false`. So for a - native condition `WriteAlarmCondition` neither projects nor compares those - two fields — otherwise the driver's echo of an ack would report a second - event, and the next driver transition would undo (and report) a local - shelve. A native condition starts Confirmed; Confirm and the shelve methods - are still not routed to the driver (local only, as before), but they now - stick. Scripted conditions are unchanged: the engine models both fields. -- **TimedShelve works.** `Create()` left `MaxTimeShelved` at 0, and the SDK - rejects any shelving time above it, so every client TimedShelve failed with - `BadShelvingTimeOutOfRange`. It is now `int.MaxValue` ms (≈24.8 days — the - longest the SDK's own shelving timer can hold). +- **Only a real change is reported.** The condition's state is compared + before and after the SDK handler; a call that changed nothing reports + nothing. The SDK accepts acknowledging an acknowledged condition and sets + `Confirmed = false` again, so a re-ack also restores the pre-call Confirmed + and Retain — silent, and a scripted node never drifts from its engine. + AddComment always reports (the comment is the change). +- **Native conditions: Acknowledge and AddComment only** — see §"Native alarm + acknowledge → AVEVA" above. The other methods are refused + (`BadNotSupported`, no event), and a native ack keeps the condition + Confirmed. +- **TimedShelve works (scripted).** `Create()` left `MaxTimeShelved` at 0, and + the SDK rejects any shelving time above it, so every client TimedShelve + failed with `BadShelvingTimeOutOfRange`. It is now `int.MaxValue` ms (≈24.8 + days). The node applies the shelve with no SDK timer of its own; the engine + owns the expiry and its Unshelved re-projection reports the one expiry + event. - Method **audit** events (`AuditConditionAcknowledgeEventType`, …) are still the SDK's, delivered only while the server is auditing over an encrypted channel. diff --git a/docs/ScriptedAlarms.md b/docs/ScriptedAlarms.md index 02123aee..ce2717f3 100644 --- a/docs/ScriptedAlarms.md +++ b/docs/ScriptedAlarms.md @@ -352,13 +352,13 @@ On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` (containing comma **Scripted vs native conditions — Enable/Disable and Acknowledge:** - **Scripted conditions** — all eight Part 9 operations (including Enable/Disable) route through `AlarmCommandRouter` onto the `alarm-commands` topic, which `ScriptedAlarmHostActor` dispatches to the engine (`EnableAsync` / `DisableAsync` / `AcknowledgeAsync` / …). On enable, `ActiveState` is re-derived from the next predicate evaluation. -- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable` returns `BadNotSupported` immediately — native conditions have no engine-side enable/disable surface. +- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable`, `OnConfirm` and `OnShelve` return `BadNotSupported` immediately — a native condition supports Acknowledge and AddComment only (no engine-side surface, no upstream path, and the driver projection owns its Confirmed/Shelving; Gitea #587 review). `OnTimedUnshelve` (the SDK's internal auto-unshelve timer) bypasses the client gate — it is system-initiated and not subject to operator role checks. ### Delta-gate de-duplication -`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. For an OPC UA method call (Acknowledge, Confirm, AddComment, the shelve methods, Enable/Disable) the SDK applies the new state to the node, and the node manager then reports **the one** condition event for it from the method path (Gitea #587 — the SDK reports none of its own, since `AutoReportStateChanges` stays false; the shelve methods' state is applied by the node manager, because the SDK's never applies it). The engine's later re-projection of the same transition finds the node already in that state and is suppressed, so each accepted call yields exactly one event. See `docs/AlarmTracking.md` §"One condition event per method call". +`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. For an OPC UA method call (Acknowledge, Confirm, AddComment, the shelve methods, Enable/Disable) the SDK applies the new state to the node, and the node manager then reports **the one** condition event for it from the method path (Gitea #587 — the SDK reports none of its own, since `AutoReportStateChanges` stays false; the shelve methods' state is applied by the node manager, because the SDK's never applies it; a call that changed nothing — e.g. a re-ack — reports nothing). Native (driver-fed) conditions support only Acknowledge and AddComment; their Confirm, shelve and Enable/Disable methods return `BadNotSupported`. The engine's later re-projection of the same transition finds the node already in that state and is suppressed, so each accepted call yields exactly one event. See `docs/AlarmTracking.md` §"One condition event per method call". ### AdminUI path diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index a37b9dbd..6e2e910c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -829,11 +829,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Gitea #587) NEVER passed through this method, so a last-written cache would be stale and // wrongly report a delta. By the time the engine re-projects that ack here, the node already // holds the acked state → no delta → suppress. - // Gitea #587: a NATIVE condition's Confirmed and Shelving belong to the Part 9 methods, not - // to this projection — see WireMethodTransitionReporting. - var methodOwned = _nativeAlarmNodeIds.Contains(key); var current = ReadConditionDelta(condition); - var incoming = ToConditionDelta(state, condition, methodOwned); + var incoming = ToConditionDelta(state, condition); bool fire = ShouldFireConditionEvent(current, incoming); // EnabledState / AckedState / ActiveState are mandatory children — always present after @@ -845,11 +842,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 condition.SetEnableState(SystemContext, state.Enabled); condition.SetActiveState(SystemContext, state.Active); condition.SetAcknowledgedState(SystemContext, state.Acknowledged); - if (condition.ConfirmedState is not null && !methodOwned) + if (condition.ConfirmedState is not null) { condition.SetConfirmedState(SystemContext, state.Confirmed); } - if (condition.ShelvingState is not null && !methodOwned) + if (condition.ShelvingState is not null) { // SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's // (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine, not the @@ -1117,24 +1114,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// the bucket the node holds (the projection calls SetSeverity(MapSeverity(...))), and an /// optional Confirmed/Shelving that the node can't actually hold (missing child) is folded to the /// node's read-back default so it never spuriously registers as a delta. - /// True for a native condition, whose Confirmed and Shelving the projection does - /// not apply (see ); they are folded to the node's current value so the - /// snapshot's constants can never register as a delta. - private static AlarmConditionDelta ToConditionDelta( - AlarmConditionSnapshot state, AlarmConditionState condition, bool methodOwned = false) => new( + private static AlarmConditionDelta ToConditionDelta(AlarmConditionSnapshot state, AlarmConditionState condition) => new( Active: state.Active, Acknowledged: state.Acknowledged, // If the node has no ConfirmedState child, the projection is a no-op there; mirror the node's // read-back default (true) so a snapshot Confirmed value can't create a phantom delta. - Confirmed: condition.ConfirmedState is null ? true - : methodOwned ? condition.ConfirmedState.Id?.Value ?? true - : state.Confirmed, + Confirmed: condition.ConfirmedState is not null ? state.Confirmed : true, Enabled: state.Enabled, // Likewise for shelving: without a ShelvingState child the projection can't apply, so fold to the // node's read-back default (Unshelved). - Shelving: condition.ShelvingState is null ? AlarmShelvingKind.Unshelved - : methodOwned ? ReadShelvingKind(condition) - : state.Shelving, + Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved, MappedSeverity: (ushort)MapSeverity(state.Severity), Message: state.Message ?? string.Empty, // If the node has no Quality child, WriteAlarmCondition's projection is a no-op there; fold to the @@ -1294,8 +1283,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 alarm.SetEnableState(SystemContext, true); alarm.SetActiveState(SystemContext, false); alarm.SetAcknowledgedState(SystemContext, true); - // A native condition's Confirmed is method-owned (Gitea #587 — see WireMethodTransitionReporting), so - // no projection will ever set it: start it confirmed, the value the driver projection used to supply. + // A native condition is never Unconfirmed (Gitea #587 review): it has no confirm workflow — Confirm is + // refused and Acknowledge re-asserts Confirmed — so start it Confirmed, the value NativeAlarmProjector + // supplies on every transition, rather than the SDK's default false until the first projection. if (isNative && alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, true); alarm.SetSeverity(SystemContext, MapSeverity(severity)); // Gitea #587 (found by its TimedShelve test): Create() leaves MaxTimeShelved at 0, and the SDK's @@ -1320,18 +1310,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // delta-gate, which finds the node already in that state and suppresses a second event. // H6c — a NATIVE (driver-fed) condition's Acknowledge belongs to the backing driver, NOT the // scripted engine, so branch on native-ness (via the lock-guarded accessor) and route a native - // ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. Confirm/AddComment/ - // Shelve stay on the scripted path even for native conditions (H6c scope is Acknowledge only). + // ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. AddComment stays on the + // scripted path even for native conditions (H6c scope is Acknowledge only). + // Gitea #587 review: a NATIVE condition supports Acknowledge and AddComment only. Confirm and the + // shelve methods return BadNotSupported (like Enable/Disable below) — there is no upstream path for + // them, no pair replication of a local state, and the driver projection (Confirmed, Unshelved) would + // silently undo one at the next transition. alarm.OnAcknowledge = (context, condition, _, comment) => IsNativeAlarmNode(alarmNodeId, realm) ? HandleNativeAlarmAck(context, condition, comment) : HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null); alarm.OnConfirm = (context, condition, _, comment) => - HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null); + IsNativeAlarmNode(alarmNodeId, realm) + ? new ServiceResult(StatusCodes.BadNotSupported) + : HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null); alarm.OnAddComment = (context, condition, _, comment) => HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null); alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) => { + if (IsNativeAlarmNode(alarmNodeId, realm)) return new ServiceResult(StatusCodes.BadNotSupported); + // SDK invocation shapes (verified against the decompiled AlarmConditionState): // OneShotShelve → (shelving:true, oneShot:true, 0.0) ⇒ OneShotShelve, no expiry // TimedShelve → (shelving:true, oneShot:false, ms) ⇒ TimedShelve, expiry = UtcNow + ms @@ -1558,28 +1556,38 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// events. Holding it makes the re-projection wait until the node holds the new state. /// /// - /// Native conditions: Confirmed and Shelving are the methods'. A native condition's state is - /// re-projected from each driver transition by NativeAlarmProjector, which models neither - /// confirmation nor shelving — it always says Confirmed = true, Unshelved. The SDK's - /// Acknowledge sets Confirmed = false (the condition supports Confirm), so the driver's own - /// echo of that ack would otherwise flip it back and report a second event for one ack, and a - /// local shelve would be undone (and reported) by the next driver transition. So for a native - /// condition neither projects nor compares those two fields: - /// they change only through these methods, each reported once here. A scripted condition's engine - /// models both, so its projection still owns them. + /// Native conditions support Acknowledge and AddComment only (Confirm and the shelve methods + /// are refused with BadNotSupported before they get here). Their Confirmed and Shelving stay + /// owned by the driver projection (NativeAlarmProjector: always Confirmed, Unshelved). The SDK's + /// Acknowledge sets Confirmed = false because the condition type supports Confirm, which would + /// leave every native alarm Unconfirmed after its first OPC UA ack — ScadaBridge maps + /// ConfirmedState straight into its alarm state — so a native ack re-asserts Confirmed and the + /// projection's Retain rule (Active || !Acked) before the one report. /// /// - /// A rejected call (bad , e.g. acknowledging an acknowledged condition, a - /// denied gate, or Enable/Disable on a native condition) reports nothing: there was no transition. + /// Only a real change is reported. The condition's gate-relevant state is read before and after + /// the SDK handler, and nothing is reported when it did not move. The SDK accepts acknowledging an + /// acknowledged condition and sets Confirmed = false again; a re-ack therefore also restores the + /// pre-call Confirmed and Retain, so a scripted node never drifts from its engine (which ignores a + /// re-ack) and the call is silent. AddComment always reports: adding a comment is itself the change. + /// + /// + /// A rejected call (bad , e.g. a denied gate, or a refused method on a native + /// condition) reports nothing: there was no transition. /// /// private void WireMethodTransitionReporting(AlarmConditionState alarm, RealmKey conditionKey) { alarm.AutoReportStateChanges = false; // the SDK's own report stays off; ours replaces it (see remarks) - WrapComment(alarm.Acknowledge); - WrapComment(alarm.Confirm); - WrapComment(alarm.AddComment); + if (alarm.Acknowledge?.OnCall is { } acknowledge) + { + alarm.Acknowledge.OnCall = (context, m, objectId, eventId, comment) => + ReportAfterMethod(() => acknowledge(context, m, objectId, eventId, comment), apply: null, isAcknowledge: true); + } + + WrapComment(alarm.Confirm, alwaysReport: false); + WrapComment(alarm.AddComment, alwaysReport: true); WrapGeneric(alarm.Enable, apply: null); WrapGeneric(alarm.Disable, apply: null); @@ -1589,17 +1597,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 WrapGeneric(shelving.Unshelve, () => alarm.SetShelvingState(SystemContext, shelved: false, oneShot: false, shelvingTime: 0)); if (shelving.TimedShelve is { OnCall: { } timed } timedShelve) { + // shelvingTime 0, as WriteAlarmCondition projects it: the scripted engine owns the expiry and + // re-projects Unshelved when it passes (the SDK's own timer only routes an Unshelve back to it). timedShelve.OnCall = (context, method, objectId, shelvingTime) => ReportAfterMethod( () => timed(context, method, objectId, shelvingTime), () => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: 0)); } } - void WrapComment(AddCommentMethodState? method) + void WrapComment(AddCommentMethodState? method, bool alwaysReport) { if (method?.OnCall is not { } sdk) return; method.OnCall = (context, m, objectId, eventId, comment) => - ReportAfterMethod(() => sdk(context, m, objectId, eventId, comment), apply: null); + ReportAfterMethod(() => sdk(context, m, objectId, eventId, comment), apply: null, alwaysReport: alwaysReport); } void WrapGeneric(MethodState? method, Action? apply) @@ -1609,20 +1619,44 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ReportAfterMethod(() => sdk(context, m, inputs, outputs), apply); } - ServiceResult ReportAfterMethod(Func sdkHandler, Action? apply) + ServiceResult ReportAfterMethod( + Func sdkHandler, Action? apply, bool isAcknowledge = false, bool alwaysReport = false) { lock (Lock) { + var before = ReadConditionDelta(alarm); + var retainBefore = alarm.Retain.Value; + var result = sdkHandler(); // A rebuild may have discarded this instance mid-call; its replacement is not what was called. - if (ServiceResult.IsGood(result) - && _alarmConditions.TryGetValue(conditionKey, out var live) - && ReferenceEquals(live, alarm)) + if (!ServiceResult.IsGood(result) + || !_alarmConditions.TryGetValue(conditionKey, out var live) + || !ReferenceEquals(live, alarm)) { - apply?.Invoke(); - ReportConditionEvent(alarm, DateTime.UtcNow); + return result; } + apply?.Invoke(); + + if (isAcknowledge && alarm.ConfirmedState is not null) + { + if (_nativeAlarmNodeIds.Contains(conditionKey)) + { + // Native: no confirm workflow — keep what the driver projection says (see remarks). + alarm.SetConfirmedState(SystemContext, true); + alarm.Retain.Value = (alarm.ActiveState?.Id?.Value ?? false) || !(alarm.AckedState?.Id?.Value ?? true); + } + else if (before.Acknowledged) + { + // Re-ack: the SDK flipped Confirmed though nothing was acknowledged; put it back. + alarm.SetConfirmedState(SystemContext, before.Confirmed); + alarm.Retain.Value = retainBefore; + } + } + + if (alwaysReport || ReadConditionDelta(alarm) != before) + ReportConditionEvent(alarm, DateTime.UtcNow); + return result; } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs index 4b4ba023..be6eaf81 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ConditionMethodEventTests.cs @@ -50,16 +50,13 @@ public sealed class ConditionMethodEventTests [InlineData("Unshelve", false, true)] [InlineData("Disable", false, true)] [InlineData("Enable", false, true)] + // Native conditions support Acknowledge and AddComment only (the rest: A_refused_native_method_…). [InlineData("Acknowledge", true, true)] - [InlineData("Confirm", true, true)] [InlineData("AddComment", true, true)] - [InlineData("OneShotShelve", true, true)] - [InlineData("TimedShelve", true, true)] - [InlineData("Unshelve", true, true)] // Flag off: the server-wide AlarmAck role admits the call; delivery is ungated. [InlineData("Acknowledge", false, false)] [InlineData("Acknowledge", true, false)] - [InlineData("OneShotShelve", true, false)] + [InlineData("OneShotShelve", false, false)] public async Task An_accepted_method_call_reports_exactly_one_condition_event(string method, bool native, bool aclOn) { var ct = TestContext.Current.CancellationToken; @@ -97,15 +94,6 @@ public sealed class ConditionMethodEventTests rig.Project(native, current); } - // A native condition's Confirmed and Shelving are reached only through the methods. - ProjectAs("setup"); - await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "setup")); - if (native && method == "Confirm") - await CallAsync(session, condition, "Acknowledge", events.LatestEventId(folderItem, "setup"), ct); - if (native && method == "Unshelve") - await CallAsync(session, condition, "OneShotShelve", events.LatestEventId(folderItem, "setup"), ct); - await rig.DrainProjectionsAsync(); - ProjectAs("base"); await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "base") && events.Has(serverItem, "base")); @@ -121,7 +109,7 @@ public sealed class ConditionMethodEventTests { var baseEvents = events.ConditionEvents(item, "base"); baseEvents.Count.ShouldBe(2, $"the base transition plus exactly one {method} event ({item.StartNodeId})"); - AssertNewState(method, baseEvents[1]); + AssertNewState(method, native, baseEvents[1]); } if (aclOn) @@ -141,19 +129,29 @@ public sealed class ConditionMethodEventTests } /// - /// A refused call reports nothing: Disable on a native condition is BadNotSupported, so no - /// transition happened and no condition event may claim one. + /// A native condition supports Acknowledge and AddComment only: Confirm, the shelve methods and + /// Disable are refused, report nothing, and leave the condition Confirmed and Unshelved. /// - [Fact] - public async Task A_refused_method_call_reports_no_condition_event() + /// + /// Native Confirm/Shelve used to be accepted and applied locally — per node, never upstream, never to the + /// redundant peer, and undone by the driver projection (a one-shot shelve silently, a timed one never + /// expiring). Unshelve on an unshelved condition is refused by the SDK itself before our veto runs. + /// + [Theory] + [InlineData("Confirm")] + [InlineData("OneShotShelve")] + [InlineData("TimedShelve")] + [InlineData("Unshelve")] + [InlineData("Disable")] + public async Task A_refused_native_method_reports_no_condition_event(string method) { var ct = TestContext.Current.CancellationToken; - var rig = await StartAsync("CondMethodRefused", encrypted: false, ct); + var rig = await StartAsync($"CondMethodRefused{method}", encrypted: false, ct); try { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); rig.MaterialiseNative(); - var current = PreState("Disable", native: true); + var current = PreState(method, native: true); using var session = await rig.ConnectAsync(anonymous: false, ct); var events = new RecordedEvents(); @@ -166,13 +164,16 @@ public sealed class ConditionMethodEventTests await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "base")); var refused = await Should.ThrowAsync( - CallAsync(session, ConditionNodeId(session, native: true), "Disable", null, ct)); - refused.StatusCode.ShouldBe(StatusCodes.BadNotSupported); + CallAsync(session, ConditionNodeId(session, native: true), method, events.LatestEventId(serverItem, "base"), ct)); + if (method != "Unshelve") refused.StatusCode.ShouldBe(StatusCodes.BadNotSupported); rig.Project(native: true, current with { Message = "sentinel" }); await RecordedEvents.WaitUntilAsync(() => events.Has(folderItem, "sentinel") && events.Has(serverItem, "sentinel")); events.ConditionEvents(folderItem, "base").Count.ShouldBe(1); events.ConditionEvents(serverItem, "base").Count.ShouldBe(1); + var sentinel = events.ConditionEvents(serverItem, "sentinel")[0]; + sentinel[RecordedEvents.ConfirmedField].Value.ShouldBe(true); + sentinel[RecordedEvents.ShelvingField].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved); await subscription.DeleteAsync(true, ct); } @@ -214,8 +215,184 @@ public sealed class ConditionMethodEventTests finally { await rig.DisposeAsync(); } } + /// + /// An OPC UA Acknowledge on a native condition reports exactly one event with ConfirmedState still + /// true, and the condition stays Confirmed through the driver's echo, a clear and a re-activation. + /// + /// + /// The SDK's Acknowledge sets Confirmed false on any condition that supports Confirm, and nothing resets + /// it for a native alarm — ScadaBridge maps ConfirmedState into its alarm state, so every native + /// alarm would read Unconfirmed after its first HMI ack (review of 167a48bf). + /// + [Fact] + public async Task A_native_acknowledge_keeps_the_condition_confirmed_through_clear_and_reactivation() + { + var ct = TestContext.Current.CancellationToken; + var rig = await StartAsync("CondNativeAckConfirmed", encrypted: false, ct); + try + { + rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); + rig.MaterialiseNative(); + rig.Nm.NativeAlarmAckRouter = _ => { }; + + using var session = await rig.ConnectAsync(anonymous: false, ct); + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); + var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + rig.Project(true, NativeState(active: true, acked: false, "base")); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); + + await CallAsync(session, ConditionNodeId(session, true), "Acknowledge", events.LatestEventId(item, "base"), ct); + rig.Project(true, NativeState(active: true, acked: true, "base")); // the driver's echo of the ack + rig.Project(true, NativeState(active: false, acked: true, "cleared")); + rig.Project(true, NativeState(active: true, acked: false, "reactivated")); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "reactivated")); + + var baseEvents = events.ConditionEvents(item, "base"); + baseEvents.Count.ShouldBe(2, "the base transition plus the one ack event; the echo is no delta"); + baseEvents[1][RecordedEvents.AckedField].Value.ShouldBe(true); + foreach (var e in baseEvents.Concat(events.ConditionEvents(item, "cleared")).Concat(events.ConditionEvents(item, "reactivated"))) + e[RecordedEvents.ConfirmedField].Value.ShouldBe(true); + + await subscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// A scripted TimedShelve expires: the engine's expiry comes back as an Unshelved re-projection, which + /// reports exactly one unshelve event after the one shelve event. + /// + /// + /// The method path applies the shelve with shelvingTime 0, as the projection does, so the expiry is + /// the engine's (computed from the command's UnshelveAtUtc), never the SDK timer's — a node timer + /// would only route an Unshelve back to the engine anyway. The stand-in engine here schedules its expiry + /// from the real UnshelveAtUtc the method handler computed. + /// + [Fact] + public async Task A_scripted_timed_shelve_expires_with_exactly_one_unshelve_event() + { + var ct = TestContext.Current.CancellationToken; + var rig = await StartAsync("CondTimedExpiry", encrypted: false, ct); + try + { + rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); + rig.MaterialiseScripted(); + var current = PreState("TimedShelve", native: false) with { Message = "base" }; + Task? expiry = null; + rig.Nm.AlarmCommandRouter = cmd => + { + if (cmd.Operation != "TimedShelve") return; + current = current with { Shelving = AlarmShelvingKind.Timed }; + rig.ProjectLater(native: false, current); + var unshelveAt = cmd.UnshelveAtUtc!.Value; + expiry = Task.Run(async () => + { + var wait = unshelveAt - DateTime.UtcNow; + if (wait > TimeSpan.Zero) await Task.Delay(wait); + current = current with { Shelving = AlarmShelvingKind.Unshelved }; + rig.Project(native: false, current); + }); + }; + + using var session = await rig.ConnectAsync(anonymous: false, ct); + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); + var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + rig.Project(false, current); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); + + await CallAsync(session, ConditionNodeId(session, false), "TimedShelve", null, ct, shelvingTimeMs: 1000); + await rig.DrainProjectionsAsync(); + (expiry is not null).ShouldBeTrue("the TimedShelve reached the engine"); + await expiry!; + + rig.Project(false, current = current with { Message = "sentinel" }); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); + + var shelving = events.ConditionEvents(item, "base").Select(e => e[RecordedEvents.ShelvingField].Value).ToList(); + shelving.ShouldBe( + [ObjectIds.ShelvedStateMachineType_Unshelved, ObjectIds.ShelvedStateMachineType_TimedShelved, + ObjectIds.ShelvedStateMachineType_Unshelved], + "base, then exactly one shelve event, then exactly one expiry event"); + + await subscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + + /// + /// Acknowledging an acknowledged condition reports nothing, and does not flip a scripted condition's + /// Confirmed out of step with its engine. + /// + /// + /// The SDK accepts a re-ack and sets Confirmed false again; the engine ignores it. The scripted leg + /// acknowledges and confirms first, so the flip would be visible on the sentinel. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_re_acknowledge_reports_nothing_and_changes_nothing(bool native) + { + var ct = TestContext.Current.CancellationToken; + var rig = await StartAsync($"CondReAck{native}", encrypted: false, ct); + try + { + rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All)); + rig.MaterialiseNative(); + rig.MaterialiseScripted(); + var current = PreState("Acknowledge", native) with { Message = "base" }; + WireSimulatedOwners(rig, native, () => current, s => current = s); + + using var session = await rig.ConnectAsync(anonymous: false, ct); + var events = new RecordedEvents(); + var subscription = await RecordedEvents.SubscribeAsync(session, events, ct); + var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + var condition = ConditionNodeId(session, native); + rig.Project(native, current); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base")); + + await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct); + await rig.DrainProjectionsAsync(); + var expected = 2; + if (!native) + { + await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2); + await CallAsync(session, condition, "Confirm", events.LatestEventId(item, "base"), ct); + await rig.DrainProjectionsAsync(); + expected = 3; + } + + await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == expected); + await CallAsync(session, condition, "Acknowledge", events.LatestEventId(item, "base"), ct); + await rig.DrainProjectionsAsync(); + + rig.Project(native, current = current with { Message = "sentinel" }); + await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel")); + + events.ConditionEvents(item, "base").Count.ShouldBe(expected, "the re-ack must report nothing"); + var sentinel = events.ConditionEvents(item, "sentinel"); + sentinel.Count.ShouldBe(1); + sentinel[0][RecordedEvents.AckedField].Value.ShouldBe(true); + sentinel[0][RecordedEvents.ConfirmedField].Value.ShouldBe(true, "the re-ack must not unconfirm the node"); + + await subscription.DeleteAsync(true, ct); + } + finally { await rig.DisposeAsync(); } + } + // ───────────────────────────── helpers ───────────────────────────── + private static AlarmConditionSnapshot NativeState(bool active, bool acked, string message) => new( + Active: active, Acknowledged: acked, Confirmed: true, Enabled: true, + Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: message); + /// The condition state a case starts from — one in which its method is accepted. private static AlarmConditionSnapshot PreState(string method, bool native) { @@ -267,8 +444,10 @@ public sealed class ConditionMethodEventTests }; } - private static void AssertNewState(string method, VariantCollection e) + private static void AssertNewState(string method, bool native, VariantCollection e) { + // A native condition is never Unconfirmed, whatever the method (the SDK's ack would make it so). + if (native) e[RecordedEvents.ConfirmedField].Value.ShouldBe(true); switch (method) { case "Acknowledge": e[RecordedEvents.AckedField].Value.ShouldBe(true); break; @@ -287,7 +466,8 @@ public sealed class ConditionMethodEventTests /// Calls one Part 9 method on the condition by its type's method id (the SDK resolves it against the /// condition's method declarations — the shelve methods included); throws on a bad result. /// - private static async Task CallAsync(ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct) + private static async Task CallAsync( + ISession session, NodeId condition, string method, byte[]? eventId, CancellationToken ct, double shelvingTimeMs = 60_000) { var (methodId, args) = method switch { @@ -297,7 +477,7 @@ public sealed class ConditionMethodEventTests "Enable" => (MethodIds.ConditionType_Enable, []), "Disable" => (MethodIds.ConditionType_Disable, []), "OneShotShelve" => (MethodIds.AlarmConditionType_ShelvingState_OneShotShelve, []), - "TimedShelve" => (MethodIds.AlarmConditionType_ShelvingState_TimedShelve, [60_000.0]), + "TimedShelve" => (MethodIds.AlarmConditionType_ShelvingState_TimedShelve, [shelvingTimeMs]), "Unshelve" => (MethodIds.AlarmConditionType_ShelvingState_Unshelve, []), _ => throw new ArgumentOutOfRangeException(nameof(method), method, null), };