feat(phase7): route OPC UA Part 9 Acknowledge/Confirm methods to ScriptedAlarmEngine (task #24)

Gap 1 of phase-7-status.md. Intercepts AcknowledgeableConditionType_Acknowledge and
AcknowledgeableConditionType_Confirm calls in DriverNodeManager.Call and dispatches
them to ScriptedAlarmEngine so OPC UA HMI clients can acknowledge/confirm scripted alarms
in addition to the existing Admin UI path. Shelve methods deferred (per-instance NodeIds,
not well-known type MethodIds — follow-up task). AlarmEngine is now exposed through
Phase7ComposedSources so the server wire-up passes it to every DriverNodeManager. 13 new
unit tests cover dispatch kernel, identity fallback, batch handling, and error paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 05:57:33 -04:00
parent 1913bda6b8
commit ca149ce907
6 changed files with 639 additions and 16 deletions

View File

@@ -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 <see cref="IAddressSpaceBuilder"/> itself so
/// <c>GenericDriverNodeManager.BuildAddressSpaceAsync</c> can stream nodes directly into the
/// OPC UA server's namespace. PR 15's <c>MarkAsAlarmCondition</c> hook creates a sibling
/// <see cref="AlarmConditionState"/> node per alarm-flagged variable; subsequent driver
/// <see cref="OpcAlarmConditionState"/> node per alarm-flagged variable; subsequent driver
/// <c>OnAlarmEvent</c> pushes land through the returned sink to drive Activate /
/// Acknowledge / Deactivate transitions.
/// </summary>
@@ -106,12 +111,27 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private readonly Dictionary<string, ConditionSink> _conditionSinks = new(StringComparer.OrdinalIgnoreCase);
private EventHandler<AlarmConditionTransition>? _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<string, string> _scriptedAlarmIdByConditionNodeId
= new(StringComparer.OrdinalIgnoreCase);
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> 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<ServiceResult> 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);
}
/// <summary>
/// Intercepts Part 9 Acknowledge / Confirm <see cref="CallMethodRequest"/> slots that
/// target scripted alarm condition nodes and routes them to the
/// <see cref="ScriptedAlarmEngine"/>. Slots that are handled have their
/// <paramref name="errors"/> entry set to <see cref="ServiceResult.Good"/> and are
/// not touched by the caller's subsequent <c>base.Call</c> invocation.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// User identity is extracted from <paramref name="userIdentity"/>'s
/// <c>DisplayName</c>; when absent (anonymous session) the fallback is
/// <c>"opcua-client"</c> so every audit entry carries an identity. Authenticated
/// LDAP sessions populate DisplayName during <c>OtOpcUaServer.OnImpersonateUser</c>.
/// </para>
/// <para>
/// Extracted as a pure function for unit-testability — all dependencies are
/// passed explicitly; no closed-over state. The caller passes
/// <c>context.UserIdentity</c> from the <see cref="Call"/> override.
/// </para>
/// </remarks>
internal static void RouteScriptedAlarmMethodCalls(
IUserIdentity? userIdentity,
IList<CallMethodRequest> methodsToCall,
IList<CallMethodResult> results,
IList<ServiceResult> errors,
ScriptedAlarmEngine engine,
IReadOnlyDictionary<string, string> 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);
}
}
}
/// <summary>
/// Pure-function gate for a batch of <see cref="CallMethodRequest"/>. Pre-populates
/// <paramref name="errors"/> slots with <see cref="StatusCodes.BadUserAccessDenied"/>
@@ -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)

View File

@@ -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;
/// <summary>
/// Late-bind the Phase 7 engine-backed <c>IReadable</c> sources. Must be
/// called BEFORE <see cref="StartAsync"/> — once the OPC UA server starts, the
/// <see cref="OtOpcUaServer"/> ctor captures the field values + per-node
/// <see cref="DriverNodeManager"/>s are constructed. Calling this after start has
/// no effect on already-materialized node managers.
/// Late-bind the Phase 7 engine-backed <c>IReadable</c> sources and the
/// <see cref="ScriptedAlarmEngine"/>. Must be called BEFORE <see cref="StartAsync"/>
/// — once the OPC UA server starts, the <see cref="OtOpcUaServer"/> ctor captures
/// the field values + per-node <see cref="DriverNodeManager"/>s are constructed.
/// Calling this after start has no effect on already-materialized node managers.
/// </summary>
/// <param name="virtualReadable">Virtual-tag engine read adapter; null when no virtual tags.</param>
/// <param name="scriptedAlarmReadable">Scripted-alarm engine read adapter; null when no scripted alarms.</param>
/// <param name="alarmEngine">
/// The <see cref="ScriptedAlarmEngine"/> instance (task #24). When non-null,
/// <c>DriverNodeManager</c> 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.
/// </param>
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;
}
/// <summary>
@@ -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}",

View File

@@ -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<string>? 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

View File

@@ -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

View File

@@ -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<VirtualTagDefinition> ProjectVirtualTags(
@@ -192,10 +194,17 @@ public static class Phase7EngineComposer
/// <param name="VirtualReadable">Non-null when virtual tags were composed; pass to <c>OpcUaApplicationHost.virtualReadable</c>.</param>
/// <param name="ScriptedAlarmReadable">Non-null when scripted alarms were composed; pass to <c>OpcUaApplicationHost.scriptedAlarmReadable</c>.</param>
/// <param name="Disposables">Engine + source instances the caller owns. Dispose on shutdown.</param>
/// <param name="AlarmEngine">
/// The <see cref="ScriptedAlarmEngine"/> instance, non-null when scripted alarms were
/// composed. Passed to <c>OpcUaApplicationHost</c> so <c>DriverNodeManager</c> 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).
/// </param>
public sealed record Phase7ComposedSources(
IReadable? VirtualReadable,
IReadable? ScriptedAlarmReadable,
IReadOnlyList<IDisposable> Disposables)
IReadOnlyList<IDisposable> Disposables,
ScriptedAlarmEngine? AlarmEngine = null)
{
public static readonly Phase7ComposedSources Empty =
new(null, null, Array.Empty<IDisposable>());