diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs index 2819657b..cddf546c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs @@ -139,6 +139,45 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl } }); + // Wire the reverse-path native-alarm-ack router (H6e): a client Acknowledge of a NATIVE (driver-fed, + // e.g. Galaxy) Part 9 condition that passes the node manager's AlarmAck gate routes the acknowledge to + // the owning driver child. The node manager maps the inbound ack to an OpcUaServer-local NativeAlarmAck; + // HERE the host maps that to the Runtime DriverHostActor.RouteNativeAlarmAck (field-for-field) and Tells + // it into the local DriverHostActor, which Primary-gates + resolves the owning DriverInstanceActor. The + // mapping happens at this boundary so Runtime stays free of an OpcUaServer dependency. The Tell is + // fire-and-forget so the handler — which runs under the SDK's Lock — never blocks. The DriverHostActor + // ref is resolved LAZILY per ack (this StartAsync runs before the Akka DriverHostActor registers, so a + // one-shot resolve here would always miss); by ack time the registry has it, mirroring the node-write + // gateway below. + _server.SetNativeAlarmAckRouter(nativeAck => + { + try + { + if (_actorRegistry.TryGet(out var driverHost)) + { + driverHost.Tell(new DriverHostActor.RouteNativeAlarmAck( + nativeAck.ConditionNodeId, nativeAck.Comment, nativeAck.OperatorUser)); + } + else + { + // No driver host registered yet (admin-only node, or pre-registration race). The Part 9 ack + // already committed the local condition state; the missed routing surfaces as a non-applied + // upstream acknowledge, not a client-visible error. + _logger.LogWarning( + "OtOpcUaServerHostedService: native alarm ack for {ConditionNodeId} dropped — no DriverHostActor registered", + nativeAck.ConditionNodeId); + } + } + catch (Exception ex) + { + // The router runs under the SDK Lock on a server thread; a hiccup must not escape into the SDK's + // Call path. Log + drop — the client still gets Good for the condition-state change. + _logger.LogWarning(ex, + "OtOpcUaServerHostedService: failed to route native alarm ack for {ConditionNodeId}", + nativeAck.ConditionNodeId); + } + }); + // Wire the reverse-path inbound operator-write gateway: a client write to a writable equipment-tag // node that passes the node manager's WriteOperate gate routes the write to the owning driver child // (RouteNodeWrite → NodeWriteResult) via the local DriverHostActor. The node manager calls the @@ -185,6 +224,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl _deferredServiceLevel.SetInner(null); // Restore the Null write gateway so a late client write doesn't Ask a stopping DriverHostActor. _server?.SetNodeWriteGateway(null); + // Clear the native-alarm-ack router so a late ack doesn't Tell a stopping DriverHostActor. + _server?.SetNativeAlarmAckRouter(null); // Restore the Null historian so a late HistoryRead doesn't hit a disposed read client. _server?.SetHistorianDataSource(null); return Task.CompletedTask; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs index 48fbdc1d..fcc7a52d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs @@ -37,6 +37,27 @@ public sealed class OtOpcUaSdkServer : StandardServer return true; } + /// + /// Wire the reverse-path router for inbound Part 9 Acknowledge of a NATIVE (driver-fed, e.g. Galaxy) + /// condition onto the created . The host calls this after start with a + /// non-blocking router that Tells a DriverHostActor.RouteNativeAlarmAck into the local + /// DriverHostActor, which (Primary-gated) routes the acknowledge to the owning driver child. + /// Native conditions are not owned by the scripted-alarm engine, so the node manager branches their + /// inbound Acknowledge to this seam instead of the scripted path. + /// Passing null clears it. No-op (returns false) when the node manager has not been + /// created yet, so the caller can detect a too-early call (mirrors ). + /// + /// The router invoked by the native condition's Acknowledge handler once the + /// AlarmAck gate passes; may be null to clear it. + /// true when the router was set on a live node manager; false when no node + /// manager exists yet. + public bool SetNativeAlarmAckRouter(Action? router) + { + if (_otOpcUaNodeManager is null) return false; + _otOpcUaNodeManager.NativeAlarmAckRouter = router; + return true; + } + /// /// Wire the reverse-path gateway for inbound operator writes to writable equipment-tag nodes onto the /// created . The host calls this after start with an