diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
index f1c1650..ec01dff 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
@@ -5,10 +5,15 @@ using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
using ZB.MOM.WW.OtOpcUa.Server.History;
using ZB.MOM.WW.OtOpcUa.Server.Security;
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
+// AlarmConditionState exists in both Opc.Ua (the OPC UA stack's node-state class) and
+// ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms (the engine's persisted-state record). Alias the
+// OPC UA one so ConditionSink / VariableHandle keep their existing unqualified usage.
+using OpcAlarmConditionState = Opc.Ua.AlarmConditionState;
// Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation
// point) that collides with Opc.Ua.HistoryReadResult (service-layer per-node result). We
// assign driver-side results to an explicitly-aliased local and construct only the service
@@ -22,7 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
/// into OPC UA nodes. Implements itself so
/// GenericDriverNodeManager.BuildAddressSpaceAsync can stream nodes directly into the
/// OPC UA server's namespace. PR 15's MarkAsAlarmCondition hook creates a sibling
-/// node per alarm-flagged variable; subsequent driver
+/// node per alarm-flagged variable; subsequent driver
/// OnAlarmEvent pushes land through the returned sink to drive Activate /
/// Acknowledge / Deactivate transitions.
///
@@ -106,12 +111,27 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private readonly Dictionary _conditionSinks = new(StringComparer.OrdinalIgnoreCase);
private EventHandler? _alarmTransitionHandler;
+ // Task #24 — Phase 7 Gap 1: route OPC UA Part 9 Acknowledge / Confirm method calls on
+ // scripted alarm condition nodes to the ScriptedAlarmEngine so the engine state machine
+ // advances with the authenticated principal. When null, scripted-alarm method calls fall
+ // through to the stack's built-in handler (which updates the OPC UA node but does not
+ // reach the engine — the pre-task-24 gap).
+ //
+ // _scriptedAlarmIdByConditionNodeId maps the condition node's string identifier
+ // (e.g. "sal-abc123.Condition") → the ScriptedAlarmId the engine addresses by
+ // (e.g. "sal-abc123"). Populated in MarkAsAlarmCondition when the parent variable
+ // carries NodeSourceKind.ScriptedAlarm.
+ private readonly ScriptedAlarmEngine? _scriptedAlarmEngine;
+ private readonly Dictionary _scriptedAlarmIdByConditionNodeId
+ = new(StringComparer.OrdinalIgnoreCase);
+
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
IDriver driver, CapabilityInvoker invoker, ILogger logger,
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null,
IHistoryRouter? historyRouter = null,
- AlarmConditionService? alarmService = null)
+ AlarmConditionService? alarmService = null,
+ ScriptedAlarmEngine? scriptedAlarmEngine = null)
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
{
_driver = driver;
@@ -125,6 +145,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
_scriptedAlarmReadable = scriptedAlarmReadable;
_historyRouter = historyRouter;
_alarmService = alarmService;
+ _scriptedAlarmEngine = scriptedAlarmEngine;
_logger = logger;
if (_alarmService is not null)
@@ -601,9 +622,116 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
IList errors)
{
GateCallMethodRequests(methodsToCall, errors, context.UserIdentity, _authzGate, _scopeResolver);
+
+ // Task #24 — Phase 7 Gap 1: route Part 9 Acknowledge / Confirm calls that target
+ // scripted alarm condition nodes directly to the ScriptedAlarmEngine. The engine
+ // advances its Part 9 state machine with the authenticated principal (audit
+ // requirement), persists state, and emits the transition event through
+ // ScriptedAlarmSource so OPC UA alarm subscribers see the correct AckedState /
+ // ConfirmedState change. base.Call is skipped for handled slots so the stack's
+ // built-in handler (which updates the OPC UA node but doesn't call the engine)
+ // does not double-fire.
+ if (_scriptedAlarmEngine is not null)
+ {
+ RouteScriptedAlarmMethodCalls(context.UserIdentity, methodsToCall, results, errors,
+ _scriptedAlarmEngine, _scriptedAlarmIdByConditionNodeId);
+ }
+
base.Call(context, methodsToCall, results, errors);
}
+ ///
+ /// Intercepts Part 9 Acknowledge / Confirm slots that
+ /// target scripted alarm condition nodes and routes them to the
+ /// . Slots that are handled have their
+ /// entry set to and are
+ /// not touched by the caller's subsequent base.Call invocation.
+ ///
+ ///
+ ///
+ /// The OPC UA Part 9 Acknowledge method signature is:
+ /// InputArguments[0] = EventId (ByteString, ignored — scripted alarms identify by
+ /// ConditionId, not EventId), InputArguments[1] = Comment (LocalizedText).
+ /// Confirm has the same shape. Missing or null comment is treated as empty string.
+ ///
+ ///
+ /// User identity is extracted from 's
+ /// DisplayName; when absent (anonymous session) the fallback is
+ /// "opcua-client" so every audit entry carries an identity. Authenticated
+ /// LDAP sessions populate DisplayName during OtOpcUaServer.OnImpersonateUser.
+ ///
+ ///
+ /// Extracted as a pure function for unit-testability — all dependencies are
+ /// passed explicitly; no closed-over state. The caller passes
+ /// context.UserIdentity from the override.
+ ///
+ ///
+ internal static void RouteScriptedAlarmMethodCalls(
+ IUserIdentity? userIdentity,
+ IList methodsToCall,
+ IList results,
+ IList errors,
+ ScriptedAlarmEngine engine,
+ IReadOnlyDictionary conditionIdToAlarmId)
+ {
+ var user = userIdentity?.DisplayName;
+ if (string.IsNullOrWhiteSpace(user)) user = "opcua-client";
+
+ for (var i = 0; i < methodsToCall.Count; i++)
+ {
+ // Skip slots already errored (gate denied or pre-populated by the stack).
+ if (errors[i] is not null && ServiceResult.IsBad(errors[i])) continue;
+
+ var request = methodsToCall[i];
+
+ // Only handle the two well-known Part 9 method ids.
+ var isAcknowledge = request.MethodId == MethodIds.AcknowledgeableConditionType_Acknowledge;
+ var isConfirm = request.MethodId == MethodIds.AcknowledgeableConditionType_Confirm;
+ if (!isAcknowledge && !isConfirm) continue;
+
+ // ObjectId must be a string identifier so we can look it up in the index.
+ if (request.ObjectId.Identifier is not string conditionKey) continue;
+
+ if (!conditionIdToAlarmId.TryGetValue(conditionKey, out var alarmId)) continue;
+
+ // Extract the operator comment from InputArguments[1] (LocalizedText).
+ // InputArguments[0] is EventId (ByteString) — we address by alarmId, not EventId.
+ // InputArguments elements are Variant-boxed values; unbox via .Value before casting.
+ string? comment = null;
+ if (request.InputArguments?.Count >= 2
+ && request.InputArguments[1].Value is LocalizedText lt)
+ comment = lt.Text;
+
+ try
+ {
+ if (isAcknowledge)
+ engine.AcknowledgeAsync(alarmId, user, comment, CancellationToken.None)
+ .GetAwaiter().GetResult();
+ else
+ engine.ConfirmAsync(alarmId, user, comment, CancellationToken.None)
+ .GetAwaiter().GetResult();
+
+ // Mark the slot as handled so base.Call skips it. A pre-populated Good
+ // result (not null and not Bad) is the signal the base class uses to
+ // skip per-slot dispatch — set StatusCode to Good explicitly.
+ results[i] = new CallMethodResult { StatusCode = StatusCodes.Good };
+ errors[i] = ServiceResult.Good;
+ }
+ catch (ArgumentException ex)
+ {
+ // Unknown alarmId or invalid state (e.g. already acknowledged) — surface
+ // as BadInvalidArgument so the OPC UA client sees a meaningful status.
+ errors[i] = new ServiceResult(StatusCodes.BadInvalidArgument,
+ ex.Message, ex.Message);
+ }
+ catch (Exception ex)
+ {
+ errors[i] = new ServiceResult(StatusCodes.BadInternalError,
+ ex.Message, ex.Message);
+ }
+ }
+ }
+
///
/// Pure-function gate for a batch of . Pre-populates
/// slots with
@@ -774,7 +902,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
{
lock (_owner.Lock)
{
- var alarm = new AlarmConditionState(_variable)
+ var alarm = new OpcAlarmConditionState(_variable)
{
SymbolicName = _variable.BrowseName.Name + "_Condition",
ReferenceTypeId = ReferenceTypeIds.HasComponent,
@@ -822,6 +950,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var sink = new ConditionSink(_owner, alarm);
+ // Task #24 — register the condition nodeId → ScriptedAlarmId mapping so the
+ // Call override can route Acknowledge/Confirm invocations to the engine.
+ // The condition's string identifier is "{FullReference}.Condition"; the engine
+ // addresses alarms by ScriptedAlarmId (= FullReference for scripted alarms,
+ // because EquipmentNodeWalker sets FullName = ScriptedAlarmId on the attr).
+ if (_owner._scriptedAlarmEngine is not null
+ && _owner._sourceByFullRef.TryGetValue(FullReference, out var varSource)
+ && varSource == NodeSourceKind.ScriptedAlarm)
+ {
+ var conditionKey = alarm.NodeId.Identifier?.ToString();
+ if (!string.IsNullOrEmpty(conditionKey))
+ _owner._scriptedAlarmIdByConditionNodeId[conditionKey!] = FullReference;
+ }
+
// PR 2.3 — when the server-level alarm-condition service is wired, register
// this condition with it so the state machine runs server-side. The sink-map
// entry routes future TransitionRaised events back to this OPC UA node.
@@ -884,7 +1026,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
}
}
- private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
+ private sealed class ConditionSink(DriverNodeManager owner, OpcAlarmConditionState alarm)
: IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs
index 7fe3046..e5570c9 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs
@@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
using ZB.MOM.WW.OtOpcUa.Server.History;
using ZB.MOM.WW.OtOpcUa.Server.Observability;
@@ -41,6 +42,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
// the host has been DI-constructed (task #246).
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable;
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable;
+ // Task #24 — Phase 7 Gap 1. The engine is passed to every DriverNodeManager so OPC UA
+ // Part 9 Acknowledge / Confirm method calls on scripted alarm condition nodes reach the
+ // engine with the authenticated principal instead of falling through to the stack's
+ // default Part 9 no-op handler.
+ private ScriptedAlarmEngine? _scriptedAlarmEngine;
// PR 1+2.W — server-level singletons. Threaded through to OtOpcUaServer + every
// DriverNodeManager. Default null preserves existing test construction sites that
@@ -90,21 +96,31 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
public OtOpcUaServer? Server => _server;
///
- /// Late-bind the Phase 7 engine-backed IReadable sources. Must be
- /// called BEFORE — once the OPC UA server starts, the
- /// ctor captures the field values + per-node
- /// s are constructed. Calling this after start has
- /// no effect on already-materialized node managers.
+ /// Late-bind the Phase 7 engine-backed IReadable sources and the
+ /// . Must be called BEFORE
+ /// — once the OPC UA server starts, the ctor captures
+ /// the field values + per-node s are constructed.
+ /// Calling this after start has no effect on already-materialized node managers.
///
+ /// Virtual-tag engine read adapter; null when no virtual tags.
+ /// Scripted-alarm engine read adapter; null when no scripted alarms.
+ ///
+ /// The instance (task #24). When non-null,
+ /// DriverNodeManager routes OPC UA Part 9 Acknowledge/Confirm method calls
+ /// on scripted alarm condition nodes directly to the engine with the authenticated
+ /// principal, producing correct audit entries and state-machine transitions.
+ ///
public void SetPhase7Sources(
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable,
- ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable)
+ ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable,
+ ScriptedAlarmEngine? alarmEngine = null)
{
if (_server is not null)
throw new InvalidOperationException(
"Phase 7 sources must be set before OpcUaApplicationHost.StartAsync; the OtOpcUaServer + DriverNodeManagers have already captured the previous values.");
_virtualReadable = virtualReadable;
_scriptedAlarmReadable = scriptedAlarmReadable;
+ _scriptedAlarmEngine = alarmEngine;
}
///
@@ -149,7 +165,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
anonymousRoles: _options.AnonymousRoles,
- historyRouter: _historyRouter, alarmConditionService: _alarmConditionService);
+ historyRouter: _historyRouter, alarmConditionService: _alarmConditionService,
+ scriptedAlarmEngine: _scriptedAlarmEngine);
await _application.Start(_server).ConfigureAwait(false);
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs
index ed181e0..25e151b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs
@@ -6,6 +6,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
using ZB.MOM.WW.OtOpcUa.Server.History;
using ZB.MOM.WW.OtOpcUa.Server.Security;
@@ -35,6 +36,9 @@ public sealed class OtOpcUaServer : StandardServer
// dispatch — identical to pre-Phase-7 behaviour.
private readonly IReadable? _virtualReadable;
private readonly IReadable? _scriptedAlarmReadable;
+ // Task #24 — Gap 1. Passed through to every DriverNodeManager so OPC UA Part 9
+ // Acknowledge/Confirm method calls on scripted alarm condition nodes reach the engine.
+ private readonly ScriptedAlarmEngine? _scriptedAlarmEngine;
// PR 1+2.W — server-level singletons shared across every DriverNodeManager.
// Null when the deployment hasn't opted into the new server-side history routing /
@@ -68,7 +72,8 @@ public sealed class OtOpcUaServer : StandardServer
IReadable? scriptedAlarmReadable = null,
IReadOnlyList? anonymousRoles = null,
IHistoryRouter? historyRouter = null,
- AlarmConditionService? alarmConditionService = null)
+ AlarmConditionService? alarmConditionService = null,
+ ScriptedAlarmEngine? scriptedAlarmEngine = null)
{
_driverHost = driverHost;
_authenticator = authenticator;
@@ -82,6 +87,7 @@ public sealed class OtOpcUaServer : StandardServer
_anonymousRoles = anonymousRoles ?? [];
_historyRouter = historyRouter;
_alarmConditionService = alarmConditionService;
+ _scriptedAlarmEngine = scriptedAlarmEngine;
_loggerFactory = loggerFactory;
}
@@ -116,7 +122,8 @@ public sealed class OtOpcUaServer : StandardServer
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
authzGate: _authzGate, scopeResolver: _scopeResolver,
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
- historyRouter: _historyRouter, alarmService: _alarmConditionService);
+ historyRouter: _historyRouter, alarmService: _alarmConditionService,
+ scriptedAlarmEngine: _scriptedAlarmEngine);
// The router stays empty after PR 1+2.W — DriverNodeManager's internal
// LegacyDriverHistoryAdapter handles every driver that still implements
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs
index ca7cd66..a4afb64 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs
@@ -56,7 +56,8 @@ public sealed class OpcUaServerService(
// — late binding after server start is rejected with InvalidOperationException.
// No-op when the generation has no virtual tags or scripted alarms.
var phase7 = await phase7Composer.PrepareAsync(gen, stoppingToken);
- applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable);
+ applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable,
+ phase7.AlarmEngine);
// Phase 6.2 Stream C wiring — build the AuthorizationGate + NodeScopeResolver
// from the published generation's NodeAcl rows and the populated equipment
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs
index b93e2a1..1414331 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs
@@ -74,6 +74,7 @@ public static class Phase7EngineComposer
}
IReadable? alarmReadable = null;
+ ScriptedAlarmEngine? composedAlarmEngine = null;
if (scriptedAlarms.Count > 0)
{
var alarmDefs = ProjectScriptedAlarms(scriptedAlarms, scriptById).ToList();
@@ -89,11 +90,12 @@ public static class Phase7EngineComposer
// instead of BadNotFound. ScriptedAlarmSource stays registered as IAlarmSource
// for the event stream; the IReadable is a separate adapter over the same engine.
alarmReadable = new ScriptedAlarmReadable(alarmEngine);
+ composedAlarmEngine = alarmEngine;
disposables.Add(alarmEngine);
disposables.Add(alarmSource);
}
- return new Phase7ComposedSources(vtSource, alarmReadable, disposables);
+ return new Phase7ComposedSources(vtSource, alarmReadable, disposables, composedAlarmEngine);
}
internal static IEnumerable ProjectVirtualTags(
@@ -192,10 +194,17 @@ public static class Phase7EngineComposer
/// Non-null when virtual tags were composed; pass to OpcUaApplicationHost.virtualReadable.
/// Non-null when scripted alarms were composed; pass to OpcUaApplicationHost.scriptedAlarmReadable.
/// Engine + source instances the caller owns. Dispose on shutdown.
+///
+/// The instance, non-null when scripted alarms were
+/// composed. Passed to OpcUaApplicationHost so DriverNodeManager can
+/// route OPC UA Part 9 Acknowledge / Confirm method invocations directly to the engine
+/// with the authenticated principal (task #24 — Gap 1 of phase-7-status.md).
+///
public sealed record Phase7ComposedSources(
IReadable? VirtualReadable,
IReadable? ScriptedAlarmReadable,
- IReadOnlyList Disposables)
+ IReadOnlyList Disposables,
+ ScriptedAlarmEngine? AlarmEngine = null)
{
public static readonly Phase7ComposedSources Empty =
new(null, null, Array.Empty());
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmMethodRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmMethodRoutingTests.cs
new file mode 100644
index 0000000..ddcf95c
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmMethodRoutingTests.cs
@@ -0,0 +1,447 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Serilog;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
+using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
+using ZB.MOM.WW.OtOpcUa.Core.Scripting;
+using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Server.Phase7;
+using CoreAlarmConditionState = ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.AlarmConditionState;
+
+namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
+
+///
+/// Task #24 — Gap 1 of phase-7-status.md. Covers
+/// which intercepts
+/// OPC UA Part 9 Acknowledge / Confirm method invocations on scripted alarm condition
+/// nodes and routes them to , and the
+/// property added to expose the
+/// engine through the composition chain.
+///
+[Trait("Category", "Unit")]
+public sealed class ScriptedAlarmMethodRoutingTests
+{
+ // -----------------------------------------------------------------------
+ // Phase7ComposedSources — AlarmEngine property
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void Compose_ScriptedAlarm_rows_exposes_AlarmEngine()
+ {
+ var scripts = new[] { ScriptRow("scr-1", "return false;") };
+ var alarms = new[] { AlarmRow("al-1", "scr-1") };
+
+ var result = Phase7EngineComposer.Compose(
+ scripts, [], alarms,
+ upstream: new CachedTagUpstreamSource(),
+ alarmStateStore: new InMemoryAlarmStateStore(),
+ historianSink: NullAlarmHistorianSink.Instance,
+ rootScriptLogger: new LoggerConfiguration().CreateLogger(),
+ loggerFactory: NullLoggerFactory.Instance);
+
+ result.AlarmEngine.ShouldNotBeNull("engine is exposed so the server can route method calls");
+ result.ScriptedAlarmReadable.ShouldNotBeNull();
+ result.Disposables.Count.ShouldBeGreaterThan(0);
+ }
+
+ [Fact]
+ public void Compose_empty_rows_AlarmEngine_is_null()
+ {
+ var result = Phase7EngineComposer.Compose(
+ scripts: [],
+ virtualTags: [],
+ scriptedAlarms: [],
+ upstream: new CachedTagUpstreamSource(),
+ alarmStateStore: new InMemoryAlarmStateStore(),
+ historianSink: NullAlarmHistorianSink.Instance,
+ rootScriptLogger: new LoggerConfiguration().CreateLogger(),
+ loggerFactory: NullLoggerFactory.Instance);
+
+ result.ShouldBeSameAs(Phase7ComposedSources.Empty);
+ result.AlarmEngine.ShouldBeNull("empty composition returns the Empty sentinel with all-null engines");
+ }
+
+ [Fact]
+ public void Compose_VirtualTag_only_AlarmEngine_is_null()
+ {
+ var scripts = new[] { ScriptRow("scr-1", "return 1;") };
+ var vtags = new[] { VtRow("vt-1", "scr-1") };
+
+ var result = Phase7EngineComposer.Compose(
+ scripts, vtags, [],
+ upstream: new CachedTagUpstreamSource(),
+ alarmStateStore: new InMemoryAlarmStateStore(),
+ historianSink: NullAlarmHistorianSink.Instance,
+ rootScriptLogger: new LoggerConfiguration().CreateLogger(),
+ loggerFactory: NullLoggerFactory.Instance);
+
+ result.AlarmEngine.ShouldBeNull("no scripted alarms → alarm engine is null");
+ result.VirtualReadable.ShouldNotBeNull();
+ }
+
+ // -----------------------------------------------------------------------
+ // RouteScriptedAlarmMethodCalls — pure-function dispatch kernel
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Builds a loaded ScriptedAlarmEngine with the given alarm IDs.
+ /// All predicates return false so the alarm starts Inactive.
+ ///
+ private static ScriptedAlarmEngine BuildEngine(params string[] alarmIds)
+ {
+ var upstream = new CachedTagUpstreamSource();
+ var logger = new LoggerConfiguration().CreateLogger();
+ var factory = new ScriptLoggerFactory(logger);
+ var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
+ var defs = alarmIds.Select(id => new ScriptedAlarmDefinition(
+ AlarmId: id,
+ EquipmentPath: "/eq",
+ AlarmName: id,
+ Kind: AlarmKind.LimitAlarm,
+ Severity: AlarmSeverity.Medium,
+ MessageTemplate: "msg",
+ PredicateScriptSource: "return false;")).ToList();
+ engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
+ return engine;
+ }
+
+ ///
+ /// Builds a loaded ScriptedAlarmEngine where the named alarm starts Active
+ /// (predicate = return true) so subsequent Acknowledge tests have an
+ /// Unacknowledged state to advance.
+ ///
+ private static ScriptedAlarmEngine BuildActiveEngine(string alarmId)
+ {
+ var upstream = new CachedTagUpstreamSource();
+ var logger = new LoggerConfiguration().CreateLogger();
+ var factory = new ScriptLoggerFactory(logger);
+ var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
+ var defs = new List
+ {
+ new(AlarmId: alarmId,
+ EquipmentPath: "/eq",
+ AlarmName: alarmId,
+ Kind: AlarmKind.LimitAlarm,
+ Severity: AlarmSeverity.Medium,
+ MessageTemplate: "msg",
+ PredicateScriptSource: "return true;"),
+ };
+ engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
+ return engine;
+ }
+
+ private static IUserIdentity? MakeIdentity(string? displayName)
+ => displayName is null ? null : new NamedUserIdentity(displayName);
+
+ private static CallMethodRequest AcknowledgeRequest(string conditionNodeId, string? comment = null)
+ => new()
+ {
+ ObjectId = new NodeId(conditionNodeId, 2),
+ MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
+ InputArguments = new VariantCollection
+ {
+ new Variant(new byte[] { 1, 2, 3 }), // EventId (ByteString) — ignored
+ new Variant(new LocalizedText(comment ?? string.Empty)),
+ },
+ };
+
+ private static CallMethodRequest ConfirmRequest(string conditionNodeId, string? comment = null)
+ => new()
+ {
+ ObjectId = new NodeId(conditionNodeId, 2),
+ MethodId = MethodIds.AcknowledgeableConditionType_Confirm,
+ InputArguments = new VariantCollection
+ {
+ new Variant(new byte[] { 1, 2, 3 }), // EventId (ByteString) — ignored
+ new Variant(new LocalizedText(comment ?? string.Empty)),
+ },
+ };
+
+ private static CallMethodRequest GenericRequest(string objectNodeId)
+ => new()
+ {
+ ObjectId = new NodeId(objectNodeId, 2),
+ MethodId = new NodeId("driver-method", 2),
+ };
+
+ private static Dictionary Index(params (string condId, string alarmId)[] entries)
+ => entries.ToDictionary(e => e.condId, e => e.alarmId, StringComparer.OrdinalIgnoreCase);
+
+ // ---- no-op paths -------------------------------------------------------
+
+ [Fact]
+ public void No_index_entries_leaves_all_slots_untouched()
+ {
+ using var engine = BuildActiveEngine("al-1");
+ var calls = new List
+ {
+ AcknowledgeRequest("al-1.Condition"),
+ };
+ var results = new List { new CallMethodResult() };
+ var errors = new List { null! };
+
+ DriverNodeManager.RouteScriptedAlarmMethodCalls(
+ MakeIdentity("alice"), calls, results, errors, engine,
+ conditionIdToAlarmId: new Dictionary());
+
+ errors[0].ShouldBeNull("no matching entry → slot left for base.Call");
+ }
+
+ [Fact]
+ public void Non_alarm_method_id_is_ignored()
+ {
+ using var engine = BuildEngine("al-1");
+ var calls = new List
+ {
+ GenericRequest("al-1.Condition"),
+ };
+ var results = new List { new CallMethodResult() };
+ var errors = new List { null! };
+ var index = Index(("al-1.Condition", "al-1"));
+
+ DriverNodeManager.RouteScriptedAlarmMethodCalls(
+ MakeIdentity("alice"), calls, results, errors, engine, index);
+
+ errors[0].ShouldBeNull("non-Acknowledge/Confirm methods pass through untouched");
+ }
+
+ [Fact]
+ public void Already_errored_slot_is_skipped()
+ {
+ using var engine = BuildActiveEngine("al-1");
+ var calls = new List
+ {
+ AcknowledgeRequest("al-1.Condition"),
+ };
+ var results = new List { new CallMethodResult() };
+ var priorError = new ServiceResult(StatusCodes.BadUserAccessDenied);
+ var errors = new List { priorError };
+ var index = Index(("al-1.Condition", "al-1"));
+
+ DriverNodeManager.RouteScriptedAlarmMethodCalls(
+ MakeIdentity("alice"), calls, results, errors, engine, index);
+
+ // Pre-populated bad error must not be overwritten.
+ errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied);
+ }
+
+ // ---- Acknowledge -------------------------------------------------------
+
+ [Fact]
+ public void Acknowledge_on_active_alarm_advances_engine_state()
+ {
+ using var engine = BuildActiveEngine("al-1");
+
+ // Sanity: alarm must start unacknowledged after activation.
+ engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
+
+ var calls = new List { AcknowledgeRequest("al-1.Condition", "looks ok") };
+ var results = new List { new CallMethodResult() };
+ var errors = new List { null! };
+ var index = Index(("al-1.Condition", "al-1"));
+
+ DriverNodeManager.RouteScriptedAlarmMethodCalls(
+ MakeIdentity("ops-user"), calls, results, errors, engine, index);
+
+ errors[0].ShouldNotBeNull();
+ ServiceResult.IsBad(errors[0]).ShouldBeFalse("Acknowledge succeeded");
+ engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
+ engine.GetState("al-1")!.LastAckUser.ShouldBe("ops-user");
+ engine.GetState("al-1")!.LastAckComment.ShouldBe("looks ok");
+ }
+
+ [Fact]
+ public void Acknowledge_uses_opcua_client_as_fallback_when_identity_is_null()
+ {
+ using var engine = BuildActiveEngine("al-1");
+
+ var calls = new List { AcknowledgeRequest("al-1.Condition") };
+ var results = new List { new CallMethodResult() };
+ var errors = new List { null! };
+ var index = Index(("al-1.Condition", "al-1"));
+
+ // Pass null identity (anonymous session).
+ DriverNodeManager.RouteScriptedAlarmMethodCalls(
+ userIdentity: null, calls, results, errors, engine, index);
+
+ engine.GetState("al-1")!.LastAckUser.ShouldBe("opcua-client");
+ }
+
+ [Fact]
+ public void Acknowledge_with_no_input_arguments_uses_null_comment()
+ {
+ using var engine = BuildActiveEngine("al-1");
+ // Build a request without InputArguments to simulate a client that omits the comment.
+ var requestNoArgs = new CallMethodRequest
+ {
+ ObjectId = new NodeId("al-1.Condition", 2),
+ MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
+ };
+
+ var calls = new List { requestNoArgs };
+ var results = new List { new CallMethodResult() };
+ var errors = new List { null! };
+ var index = Index(("al-1.Condition", "al-1"));
+
+ // Should not throw — comment defaults to null.
+ DriverNodeManager.RouteScriptedAlarmMethodCalls(
+ MakeIdentity("alice"), calls, results, errors, engine, index);
+
+ ServiceResult.IsBad(errors[0]).ShouldBeFalse("Acknowledge without comment succeeds");
+ }
+
+ [Fact]
+ public void Acknowledge_marks_slot_result_as_Good_and_error_as_Good()
+ {
+ using var engine = BuildActiveEngine("al-1");
+
+ var calls = new List { AcknowledgeRequest("al-1.Condition") };
+ var results = new List { new CallMethodResult() };
+ var errors = new List { null! };
+ var index = Index(("al-1.Condition", "al-1"));
+
+ DriverNodeManager.RouteScriptedAlarmMethodCalls(
+ MakeIdentity("alice"), calls, results, errors, engine, index);
+
+ results[0].StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
+ errors[0].ShouldBe(ServiceResult.Good);
+ }
+
+ // ---- Confirm -----------------------------------------------------------
+
+ [Fact]
+ public void Confirm_on_alarm_with_unconfirmed_state_advances_state()
+ {
+ // Build an alarm pre-seeded as Inactive + Acknowledged + Unconfirmed so
+ // ApplyConfirm has a valid transition to execute.
+ var store = new InMemoryAlarmStateStore();
+ var upstream = new CachedTagUpstreamSource();
+ var logger = new LoggerConfiguration().CreateLogger();
+ var factory = new ScriptLoggerFactory(logger);
+ var engine = new ScriptedAlarmEngine(upstream, store, factory, logger);
+
+ var seedState = CoreAlarmConditionState.Fresh("confirm-alarm", DateTime.UtcNow) with
+ {
+ Active = AlarmActiveState.Inactive,
+ Acked = AlarmAckedState.Acknowledged,
+ Confirmed = AlarmConfirmedState.Unconfirmed,
+ };
+ store.SaveAsync(seedState, CancellationToken.None).GetAwaiter().GetResult();
+
+ var defs = new List
+ {
+ new("confirm-alarm", "/eq", "confirm-alarm", AlarmKind.LimitAlarm,
+ AlarmSeverity.Low, "msg", "return false;"),
+ };
+ engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
+
+ engine.GetState("confirm-alarm")!.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
+
+ var calls = new List
+ {
+ ConfirmRequest("confirm-alarm.Condition", "all clear"),
+ };
+ var results = new List { new CallMethodResult() };
+ var errors = new List { null! };
+ var index = Index(("confirm-alarm.Condition", "confirm-alarm"));
+
+ DriverNodeManager.RouteScriptedAlarmMethodCalls(
+ MakeIdentity("ops-user"), calls, results, errors, engine, index);
+
+ ServiceResult.IsBad(errors[0]).ShouldBeFalse("Confirm succeeded");
+ engine.GetState("confirm-alarm")!.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
+ engine.GetState("confirm-alarm")!.LastConfirmUser.ShouldBe("ops-user");
+ }
+
+ // ---- Mixed batches -----------------------------------------------------
+
+ [Fact]
+ public void Mixed_batch_handles_each_slot_independently()
+ {
+ using var engine = BuildActiveEngine("al-1");
+
+ var calls = new List
+ {
+ AcknowledgeRequest("al-1.Condition"), // scripted alarm → handled
+ GenericRequest("some-driver-method"), // non-alarm → pass through
+ AcknowledgeRequest("unknown-alarm.Condition"), // not in index → pass through
+ };
+ var results = Enumerable.Range(0, 3).Select(_ => new CallMethodResult()).ToList();
+ var errors = new List { null!, null!, null! };
+ var index = Index(("al-1.Condition", "al-1")); // only one entry
+
+ DriverNodeManager.RouteScriptedAlarmMethodCalls(
+ MakeIdentity("alice"), calls, results, errors, engine, index);
+
+ // Slot 0: Acknowledge on known scripted alarm → handled with Good result.
+ ServiceResult.IsBad(errors[0]).ShouldBeFalse("scripted alarm Acknowledge handled");
+ engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
+
+ // Slot 1: Generic method → left null for base.Call.
+ errors[1].ShouldBeNull("generic method left for base.Call");
+
+ // Slot 2: Unknown alarm id → left null for base.Call.
+ errors[2].ShouldBeNull("unknown condition id left for base.Call");
+ }
+
+ [Fact]
+ public void Unknown_alarm_id_in_engine_returns_BadInvalidArgument()
+ {
+ using var engine = BuildEngine("al-1");
+
+ // The index says al-999 maps to "al-999-engine" but the engine has no such alarm.
+ var calls = new List
+ {
+ AcknowledgeRequest("al-999.Condition"),
+ };
+ var results = new List { new CallMethodResult() };
+ var errors = new List { null! };
+ // Put a deliberately wrong alarmId in the index (engine will throw ArgumentException).
+ var index = Index(("al-999.Condition", "al-999-not-in-engine"));
+
+ DriverNodeManager.RouteScriptedAlarmMethodCalls(
+ MakeIdentity("alice"), calls, results, errors, engine, index);
+
+ ServiceResult.IsBad(errors[0]).ShouldBeTrue("unknown alarm in engine → error result");
+ errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
+ }
+
+ // ---- Phase7ComposedSources helpers -------------------------------------
+
+ private static Script ScriptRow(string id, string source) => new()
+ {
+ ScriptRowId = Guid.NewGuid(), GenerationId = 1,
+ ScriptId = id, Name = id, SourceCode = source, SourceHash = "h",
+ };
+
+ private static VirtualTag VtRow(string id, string scriptId) => new()
+ {
+ VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
+ VirtualTagId = id, EquipmentId = "eq-1", Name = id,
+ DataType = "Float32", ScriptId = scriptId,
+ };
+
+ private static ScriptedAlarm AlarmRow(string id, string scriptId) => new()
+ {
+ ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
+ ScriptedAlarmId = id, EquipmentId = "eq-1", Name = id,
+ AlarmType = "LimitAlarm", Severity = 500,
+ MessageTemplate = "x", PredicateScriptId = scriptId,
+ };
+
+ // ---- Fake user identity ------------------------------------------------
+
+ ///
+ /// Simple with a display name for unit testing.
+ /// Uses the UserIdentity(username, password) constructor so the base-class
+ /// property returns the supplied name when
+ /// accessed through the interface.
+ /// The real production identity is OtOpcUaServer.RoleBasedIdentity which
+ /// populates DisplayName from the LDAP authentication result.
+ ///
+ private sealed class NamedUserIdentity(string displayName) : UserIdentity(displayName, "") { }
+}