diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs index 34994e2a..7def14ed 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmCommand.cs @@ -18,6 +18,9 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; /// The Part 9 operation, one of: Acknowledge, Confirm, OneShotShelve, /// TimedShelve, Unshelve, Enable, Disable, AddComment. These map /// 1:1 onto the engine's Part9StateMachine.Apply* calls on the consuming side (T19). +/// Note: Enable and Disable are part of the vocabulary but are not yet wired at the +/// node-manager seam (T18 wired Acknowledge/Confirm/AddComment/Shelve/TimedUnshelve only); +/// OnEnableDisable delegate wiring is a future task. /// /// The acting user — the authenticated session identity's display/name. /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index c61295b2..9261dd2b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -361,10 +361,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 : ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime)); return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt); }; - // The auto-unshelve timer firing is an unshelve transition driven by the SDK (no client user); - // route it as Unshelve so the engine clears its shelve state. Same AlarmAck gate applies. + // The auto-unshelve timer callback is SDK-initiated (the TimedShelve duration expired); the SDK + // fires it with the node manager's system context — there is NO session and NO user identity. + // Routing through HandleAlarmCommand would hit the AlarmAck gate and return BadUserAccessDenied, + // leaving the alarm permanently shelved. Instead, bypass the client gate, extract the AlarmId the + // same way HandleAlarmCommand does, and route an Unshelve command so the engine clears its shelve + // state. The manual-client Unshelve path goes through OnShelve(shelving:false) and stays gated. alarm.OnTimedUnshelve = (context, condition) => - HandleAlarmCommand(context, condition, "Unshelve", comment: null, unshelveAt: null); + { + var alarmId = condition.NodeId.Identifier?.ToString() ?? string.Empty; + AlarmCommandRouter?.Invoke(new AlarmCommand(alarmId, "Unshelve", string.Empty, null, null)); + return ServiceResult.Good; + }; parent.AddChild(alarm); 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 60bdfbf6..1594c2c1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs @@ -268,10 +268,12 @@ public sealed class AlarmCommandRouterTests : IDisposable await host.DisposeAsync(); } - /// OnTimedUnshelve with AlarmAck maps to the Unshelve operation (the timer fired, the alarm - /// auto-unshelves). + /// OnTimedUnshelve fires with the SDK's system context (no session, no user identity) — + /// the real SDK path when a TimedShelve duration expires. The gate must NOT veto: the result must be + /// Good, the router must be invoked exactly once with Operation == "Unshelve" and User == empty, + /// and no UnshelveAtUtc is carried. [Fact] - public async Task OnTimedUnshelve_with_AlarmAck_routes_unshelve_operation() + public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_unshelve() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; @@ -284,12 +286,18 @@ public sealed class AlarmCommandRouterTests : IDisposable condition.ShouldNotBeNull(); condition!.OnTimedUnshelve.ShouldNotBeNull(); - var ctx = SessionContext(server, "ivan", OpcUaDataPlaneRoles.AlarmAck); + // Reproduce the real SDK path: system context has no session and no UserIdentity — exactly what + // the SDK's internal timer fires the callback with when a TimedShelve duration expires. + var ctx = new ServerSystemContext(server.CurrentInstance); // no UserIdentity set var result = condition.OnTimedUnshelve!(ctx, condition); result.ShouldBe(ServiceResult.Good); captured.Count.ShouldBe(1); - captured[0].Operation.ShouldBe("Unshelve"); + var cmd = captured[0]; + cmd.AlarmId.ShouldBe("alm-tu"); // == ScriptedAlarmId / condition NodeId identifier + cmd.Operation.ShouldBe("Unshelve"); + cmd.User.ShouldBe(string.Empty); // no client principal — system-initiated + cmd.UnshelveAtUtc.ShouldBeNull(); await host.DisposeAsync(); }