From a6d9de091bc1415bf785d0ec8025b77fb627e0a0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 14:33:58 -0400 Subject: [PATCH] feat(alarms): native condition Acknowledge routes to NativeAlarmAckRouter with principal [H6c] --- .../NativeAlarmAck.cs | 20 ++++ .../OtOpcUaNodeManager.cs | 67 ++++++++++++- .../AlarmCommandRouterTests.cs | 98 +++++++++++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs new file mode 100644 index 00000000..7595ebd2 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/NativeAlarmAck.cs @@ -0,0 +1,20 @@ +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; + +/// +/// H6c — the payload routed when an OPC UA client Acknowledges a NATIVE (driver-fed, e.g. Galaxy) +/// Part 9 condition. The scripted-alarm engine does not own native conditions, so the node manager +/// branches a native condition's inbound Acknowledge to a separate +/// seam (the host later wires it to the backing +/// driver) instead of the scripted . +/// +/// This record is intentionally OpcUaServer-local (the smallest scope) and Akka-free: it carries +/// exactly what the driver-bound router needs — the condition node id to resolve back to a driver +/// ref, the operator's acknowledge comment, and the authenticated operator's display name. +/// +/// +/// The acknowledged condition's folder-scoped NodeId identifier string +/// (the same value the DriverHostActor inverse map keys native conditions by), used to resolve the +/// command back to its backing driver + alarm ref. +/// The operator's acknowledge comment text, or null when none was supplied. +/// The authenticated operator's display name (empty when none resolves). +public sealed record NativeAlarmAck(string ConditionNodeId, string? Comment, string OperatorUser); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index d96d993f..c17ac430 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -97,6 +97,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// public Action? AlarmCommandRouter { get; set; } + /// + /// H6c — reverse-path sink for an inbound OPC UA Acknowledge on a NATIVE (driver-fed, e.g. Galaxy) + /// Part 9 condition. The scripted-alarm engine does not own native conditions, so when a client + /// Acknowledges one, the condition's OnAcknowledge handler (wired in + /// ) branches on and — after + /// the same AlarmAck role gate as — invokes THIS delegate + /// with a instead of routing an to the + /// scripted engine. The host sets it at boot to a non-blocking dispatch toward the backing driver + /// (a later task wires the driver linkage). + /// + /// Like , the handler delegate runs under the manager's + /// Lock, so the invoked action MUST be non-blocking (fire-and-forget). Null (the default) + /// makes the native-ack handler a safe no-op — it still gates + returns, just routes nowhere. + /// Only the Acknowledge of a native condition uses this seam; Confirm/AddComment/Shelve on a + /// native condition stay on the scripted path (H6c scope is the + /// Acknowledge → driver path only). + /// + /// + public Action? NativeAlarmAckRouter { get; set; } + private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance; /// @@ -602,8 +622,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // T20: the engine re-projects that same logical transition through WriteAlarmCondition; its // delta-gate (compares against the node's current state, which the SDK already pre-applied) // sees no change and suppresses the would-be second event (E3) — so no double-emit. + // 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). alarm.OnAcknowledge = (context, condition, _, comment) => - HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null); + IsNativeAlarmNode(alarmNodeId) + ? HandleNativeAlarmAck(context, condition, comment) + : HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null); alarm.OnConfirm = (context, condition, _, comment) => HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null); alarm.OnAddComment = (context, condition, _, comment) => @@ -716,6 +742,45 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return ServiceResult.Good; } + /// + /// H6c — handler body for an inbound OPC UA Acknowledge on a NATIVE (driver-fed) condition. The + /// scripted engine does not own native conditions, so this routes a to + /// (the driver-bound seam) rather than a scripted + /// to . The calling principal is resolved + /// and the AlarmAck role gate applied EXACTLY as in + /// (fails closed: a missing identity or missing role is denied — no route, no state mutation). + /// + /// The SDK context the handler delegate was invoked with — a + /// ServerSystemContext (an ) carrying the session identity + /// (T17 attached the LDAP roles as a ). + /// The condition the Acknowledge targets; its NodeId identifier is the + /// folder-scoped condition node id the driver-bound router resolves back to a driver ref. + /// The acknowledge comment text, or null when none was supplied. + /// ServiceResult.Good when allowed (the SDK then applies state + auto-fires its event); + /// BadUserAccessDenied when the gate vetoes (no route, no state mutation). + private ServiceResult HandleNativeAlarmAck(ISystemContext context, ConditionState condition, LocalizedText? comment) + { + // Resolve + gate the SAME way HandleAlarmCommand does so native and scripted acks share one authz + // contract. Anonymous / non-role-carrying identities ⇒ null ⇒ denied (fail closed, never route). + var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity; + if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase)) + { + return new ServiceResult(StatusCodes.BadUserAccessDenied); + } + + // Same condition-node-id extraction HandleAlarmCommand uses (condition.NodeId.Identifier?.ToString()): + // for a native condition this is the folder-scoped NodeId string the DriverHostActor inverse map + // (_alarmNodeIdByDriverRef value set) keys by, so the next task can resolve it back to (driver, ref). + // Non-blocking by contract (host wires a fire-and-forget dispatch); safe to call under Lock. + NativeAlarmAckRouter?.Invoke(new NativeAlarmAck( + ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty, + Comment: comment?.Text, + OperatorUser: identity.DisplayName ?? string.Empty)); + + // Good ⇒ the SDK applies the node-state change + auto-fires its own condition event. + return ServiceResult.Good; + } + /// /// The attached to a writable equipment-tag variable by /// (Task 11). The OPC UA SDK invokes it when a client writes the diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs index 047f17a8..c2fbb8a2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs @@ -411,6 +411,104 @@ public sealed class AlarmCommandRouterTests : IDisposable await host.DisposeAsync(); } + /// H6c — a NATIVE (driver-fed) condition's Acknowledge routes to (the driver-bound seam), NOT the scripted + /// . With the AlarmAck role present, OnAcknowledge + /// returns Good, the captured carries the condition NodeId, the operator + /// DisplayName, and the comment text, and the scripted router is NOT invoked. + [Fact] + public async Task Native_OnAcknowledge_routes_to_NativeAlarmAckRouter_not_scripted() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + var scripted = new List(); + var native = new List(); + nm.AlarmCommandRouter = scripted.Add; + nm.NativeAlarmAckRouter = native.Add; + + nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1"); + nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); + var condition = nm.TryGetAlarmCondition("alm-nak1"); + condition.ShouldNotBeNull(); + condition!.OnAcknowledge.ShouldNotBeNull(); + + var ctx = SessionContext(server, "nora", OpcUaDataPlaneRoles.AlarmAck); + var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("native ack")); + + result.ShouldBe(ServiceResult.Good); + native.Count.ShouldBe(1); + native[0].ConditionNodeId.ShouldBe("alm-nak1"); // folder-scoped condition NodeId identifier + native[0].OperatorUser.ShouldBe("nora"); + native[0].Comment.ShouldBe("native ack"); + scripted.ShouldBeEmpty(); // scripted engine NOT involved + + await host.DisposeAsync(); + } + + /// H6c — a SCRIPTED condition's Acknowledge still routes through the scripted + /// (Operation == "Acknowledge"); the native + /// router is NOT invoked. + [Fact] + public async Task Scripted_OnAcknowledge_still_uses_AlarmCommandRouter() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + var scripted = new List(); + var native = new List(); + nm.AlarmCommandRouter = scripted.Add; + nm.NativeAlarmAckRouter = native.Add; + + nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2"); + nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); + var condition = nm.TryGetAlarmCondition("alm-nak2"); + condition.ShouldNotBeNull(); + condition!.OnAcknowledge.ShouldNotBeNull(); + + var ctx = SessionContext(server, "owen", OpcUaDataPlaneRoles.AlarmAck); + var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("scripted ack")); + + result.ShouldBe(ServiceResult.Good); + scripted.Count.ShouldBe(1); + scripted[0].AlarmId.ShouldBe("alm-nak2"); + scripted[0].Operation.ShouldBe("Acknowledge"); + scripted[0].User.ShouldBe("owen"); + scripted[0].Comment.ShouldBe("scripted ack"); + native.ShouldBeEmpty(); // native seam NOT involved + + await host.DisposeAsync(); + } + + /// H6c — a NATIVE condition's Acknowledge from an anonymous / role-less identity is vetoed + /// (BadUserAccessDenied) and NEITHER router is invoked — the gate fails closed before any route. + [Fact] + public async Task Native_OnAcknowledge_anonymous_is_denied() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + var scripted = new List(); + var native = new List(); + nm.AlarmCommandRouter = scripted.Add; + nm.NativeAlarmAckRouter = native.Add; + + nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3"); + nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); + var condition = nm.TryGetAlarmCondition("alm-nak3"); + condition.ShouldNotBeNull(); + + // ServerSystemContext with no UserIdentity ⇒ anonymous / no role-carrying identity. + var ctx = new ServerSystemContext(server.CurrentInstance); + var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x")); + + result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); + native.ShouldBeEmpty(); + scripted.ShouldBeEmpty(); + + await host.DisposeAsync(); + } + /// A null router is a safe no-op: handler still gates + returns Good, just routes nowhere. [Fact] public async Task OnAcknowledge_with_null_router_is_safe_noop_and_returns_good()