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:
@@ -5,10 +5,15 @@ using Opc.Ua.Server;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
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.Alarms;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.History;
|
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
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
|
// Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation
|
||||||
// point) that collides with Opc.Ua.HistoryReadResult (service-layer per-node result). We
|
// 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
|
// 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
|
/// into OPC UA nodes. Implements <see cref="IAddressSpaceBuilder"/> itself so
|
||||||
/// <c>GenericDriverNodeManager.BuildAddressSpaceAsync</c> can stream nodes directly into the
|
/// <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
|
/// 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 /
|
/// <c>OnAlarmEvent</c> pushes land through the returned sink to drive Activate /
|
||||||
/// Acknowledge / Deactivate transitions.
|
/// Acknowledge / Deactivate transitions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -106,12 +111,27 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
private readonly Dictionary<string, ConditionSink> _conditionSinks = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, ConditionSink> _conditionSinks = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private EventHandler<AlarmConditionTransition>? _alarmTransitionHandler;
|
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,
|
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
|
||||||
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
|
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
|
||||||
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
|
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
|
||||||
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null,
|
IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null,
|
||||||
IHistoryRouter? historyRouter = null,
|
IHistoryRouter? historyRouter = null,
|
||||||
AlarmConditionService? alarmService = null)
|
AlarmConditionService? alarmService = null,
|
||||||
|
ScriptedAlarmEngine? scriptedAlarmEngine = null)
|
||||||
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
|
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
|
||||||
{
|
{
|
||||||
_driver = driver;
|
_driver = driver;
|
||||||
@@ -125,6 +145,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
_scriptedAlarmReadable = scriptedAlarmReadable;
|
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||||
_historyRouter = historyRouter;
|
_historyRouter = historyRouter;
|
||||||
_alarmService = alarmService;
|
_alarmService = alarmService;
|
||||||
|
_scriptedAlarmEngine = scriptedAlarmEngine;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
if (_alarmService is not null)
|
if (_alarmService is not null)
|
||||||
@@ -601,9 +622,116 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
IList<ServiceResult> errors)
|
IList<ServiceResult> errors)
|
||||||
{
|
{
|
||||||
GateCallMethodRequests(methodsToCall, errors, context.UserIdentity, _authzGate, _scopeResolver);
|
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);
|
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>
|
/// <summary>
|
||||||
/// Pure-function gate for a batch of <see cref="CallMethodRequest"/>. Pre-populates
|
/// Pure-function gate for a batch of <see cref="CallMethodRequest"/>. Pre-populates
|
||||||
/// <paramref name="errors"/> slots with <see cref="StatusCodes.BadUserAccessDenied"/>
|
/// <paramref name="errors"/> slots with <see cref="StatusCodes.BadUserAccessDenied"/>
|
||||||
@@ -774,7 +902,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
{
|
{
|
||||||
lock (_owner.Lock)
|
lock (_owner.Lock)
|
||||||
{
|
{
|
||||||
var alarm = new AlarmConditionState(_variable)
|
var alarm = new OpcAlarmConditionState(_variable)
|
||||||
{
|
{
|
||||||
SymbolicName = _variable.BrowseName.Name + "_Condition",
|
SymbolicName = _variable.BrowseName.Name + "_Condition",
|
||||||
ReferenceTypeId = ReferenceTypeIds.HasComponent,
|
ReferenceTypeId = ReferenceTypeIds.HasComponent,
|
||||||
@@ -822,6 +950,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
|
|
||||||
var sink = new ConditionSink(_owner, alarm);
|
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
|
// 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
|
// 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.
|
// 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
|
: IAlarmConditionSink
|
||||||
{
|
{
|
||||||
public void OnTransition(AlarmEventArgs args)
|
public void OnTransition(AlarmEventArgs args)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
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.Alarms;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.History;
|
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.Observability;
|
using ZB.MOM.WW.OtOpcUa.Server.Observability;
|
||||||
@@ -41,6 +42,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
// the host has been DI-constructed (task #246).
|
// 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? _virtualReadable;
|
||||||
private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable;
|
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
|
// PR 1+2.W — server-level singletons. Threaded through to OtOpcUaServer + every
|
||||||
// DriverNodeManager. Default null preserves existing test construction sites that
|
// DriverNodeManager. Default null preserves existing test construction sites that
|
||||||
@@ -90,21 +96,31 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
public OtOpcUaServer? Server => _server;
|
public OtOpcUaServer? Server => _server;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Late-bind the Phase 7 engine-backed <c>IReadable</c> sources. Must be
|
/// Late-bind the Phase 7 engine-backed <c>IReadable</c> sources and the
|
||||||
/// called BEFORE <see cref="StartAsync"/> — once the OPC UA server starts, the
|
/// <see cref="ScriptedAlarmEngine"/>. Must be called BEFORE <see cref="StartAsync"/>
|
||||||
/// <see cref="OtOpcUaServer"/> ctor captures the field values + per-node
|
/// — once the OPC UA server starts, the <see cref="OtOpcUaServer"/> ctor captures
|
||||||
/// <see cref="DriverNodeManager"/>s are constructed. Calling this after start has
|
/// the field values + per-node <see cref="DriverNodeManager"/>s are constructed.
|
||||||
/// no effect on already-materialized node managers.
|
/// Calling this after start has no effect on already-materialized node managers.
|
||||||
/// </summary>
|
/// </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(
|
public void SetPhase7Sources(
|
||||||
ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable,
|
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)
|
if (_server is not null)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Phase 7 sources must be set before OpcUaApplicationHost.StartAsync; the OtOpcUaServer + DriverNodeManagers have already captured the previous values.");
|
"Phase 7 sources must be set before OpcUaApplicationHost.StartAsync; the OtOpcUaServer + DriverNodeManagers have already captured the previous values.");
|
||||||
_virtualReadable = virtualReadable;
|
_virtualReadable = virtualReadable;
|
||||||
_scriptedAlarmReadable = scriptedAlarmReadable;
|
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||||
|
_scriptedAlarmEngine = alarmEngine;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -149,7 +165,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
|
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
|
||||||
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
|
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
|
||||||
anonymousRoles: _options.AnonymousRoles,
|
anonymousRoles: _options.AnonymousRoles,
|
||||||
historyRouter: _historyRouter, alarmConditionService: _alarmConditionService);
|
historyRouter: _historyRouter, alarmConditionService: _alarmConditionService,
|
||||||
|
scriptedAlarmEngine: _scriptedAlarmEngine);
|
||||||
await _application.Start(_server).ConfigureAwait(false);
|
await _application.Start(_server).ConfigureAwait(false);
|
||||||
|
|
||||||
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
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.Alarms;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.History;
|
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
@@ -35,6 +36,9 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
// dispatch — identical to pre-Phase-7 behaviour.
|
// dispatch — identical to pre-Phase-7 behaviour.
|
||||||
private readonly IReadable? _virtualReadable;
|
private readonly IReadable? _virtualReadable;
|
||||||
private readonly IReadable? _scriptedAlarmReadable;
|
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.
|
// 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 /
|
// 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,
|
IReadable? scriptedAlarmReadable = null,
|
||||||
IReadOnlyList<string>? anonymousRoles = null,
|
IReadOnlyList<string>? anonymousRoles = null,
|
||||||
IHistoryRouter? historyRouter = null,
|
IHistoryRouter? historyRouter = null,
|
||||||
AlarmConditionService? alarmConditionService = null)
|
AlarmConditionService? alarmConditionService = null,
|
||||||
|
ScriptedAlarmEngine? scriptedAlarmEngine = null)
|
||||||
{
|
{
|
||||||
_driverHost = driverHost;
|
_driverHost = driverHost;
|
||||||
_authenticator = authenticator;
|
_authenticator = authenticator;
|
||||||
@@ -82,6 +87,7 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
_anonymousRoles = anonymousRoles ?? [];
|
_anonymousRoles = anonymousRoles ?? [];
|
||||||
_historyRouter = historyRouter;
|
_historyRouter = historyRouter;
|
||||||
_alarmConditionService = alarmConditionService;
|
_alarmConditionService = alarmConditionService;
|
||||||
|
_scriptedAlarmEngine = scriptedAlarmEngine;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +122,8 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
|
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
|
||||||
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
||||||
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
|
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
|
// The router stays empty after PR 1+2.W — DriverNodeManager's internal
|
||||||
// LegacyDriverHistoryAdapter handles every driver that still implements
|
// LegacyDriverHistoryAdapter handles every driver that still implements
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ public sealed class OpcUaServerService(
|
|||||||
// — late binding after server start is rejected with InvalidOperationException.
|
// — late binding after server start is rejected with InvalidOperationException.
|
||||||
// No-op when the generation has no virtual tags or scripted alarms.
|
// No-op when the generation has no virtual tags or scripted alarms.
|
||||||
var phase7 = await phase7Composer.PrepareAsync(gen, stoppingToken);
|
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
|
// Phase 6.2 Stream C wiring — build the AuthorizationGate + NodeScopeResolver
|
||||||
// from the published generation's NodeAcl rows and the populated equipment
|
// from the published generation's NodeAcl rows and the populated equipment
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ public static class Phase7EngineComposer
|
|||||||
}
|
}
|
||||||
|
|
||||||
IReadable? alarmReadable = null;
|
IReadable? alarmReadable = null;
|
||||||
|
ScriptedAlarmEngine? composedAlarmEngine = null;
|
||||||
if (scriptedAlarms.Count > 0)
|
if (scriptedAlarms.Count > 0)
|
||||||
{
|
{
|
||||||
var alarmDefs = ProjectScriptedAlarms(scriptedAlarms, scriptById).ToList();
|
var alarmDefs = ProjectScriptedAlarms(scriptedAlarms, scriptById).ToList();
|
||||||
@@ -89,11 +90,12 @@ public static class Phase7EngineComposer
|
|||||||
// instead of BadNotFound. ScriptedAlarmSource stays registered as IAlarmSource
|
// instead of BadNotFound. ScriptedAlarmSource stays registered as IAlarmSource
|
||||||
// for the event stream; the IReadable is a separate adapter over the same engine.
|
// for the event stream; the IReadable is a separate adapter over the same engine.
|
||||||
alarmReadable = new ScriptedAlarmReadable(alarmEngine);
|
alarmReadable = new ScriptedAlarmReadable(alarmEngine);
|
||||||
|
composedAlarmEngine = alarmEngine;
|
||||||
disposables.Add(alarmEngine);
|
disposables.Add(alarmEngine);
|
||||||
disposables.Add(alarmSource);
|
disposables.Add(alarmSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Phase7ComposedSources(vtSource, alarmReadable, disposables);
|
return new Phase7ComposedSources(vtSource, alarmReadable, disposables, composedAlarmEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static IEnumerable<VirtualTagDefinition> ProjectVirtualTags(
|
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="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="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="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(
|
public sealed record Phase7ComposedSources(
|
||||||
IReadable? VirtualReadable,
|
IReadable? VirtualReadable,
|
||||||
IReadable? ScriptedAlarmReadable,
|
IReadable? ScriptedAlarmReadable,
|
||||||
IReadOnlyList<IDisposable> Disposables)
|
IReadOnlyList<IDisposable> Disposables,
|
||||||
|
ScriptedAlarmEngine? AlarmEngine = null)
|
||||||
{
|
{
|
||||||
public static readonly Phase7ComposedSources Empty =
|
public static readonly Phase7ComposedSources Empty =
|
||||||
new(null, null, Array.Empty<IDisposable>());
|
new(null, null, Array.Empty<IDisposable>());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task #24 — Gap 1 of phase-7-status.md. Covers
|
||||||
|
/// <see cref="DriverNodeManager.RouteScriptedAlarmMethodCalls"/> which intercepts
|
||||||
|
/// OPC UA Part 9 Acknowledge / Confirm method invocations on scripted alarm condition
|
||||||
|
/// nodes and routes them to <see cref="ScriptedAlarmEngine"/>, and the
|
||||||
|
/// <see cref="Phase7ComposedSources.AlarmEngine"/> property added to expose the
|
||||||
|
/// engine through the composition chain.
|
||||||
|
/// </summary>
|
||||||
|
[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
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a loaded ScriptedAlarmEngine with the given alarm IDs.
|
||||||
|
/// All predicates return <c>false</c> so the alarm starts Inactive.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a loaded ScriptedAlarmEngine where the named alarm starts Active
|
||||||
|
/// (predicate = return true) so subsequent Acknowledge tests have an
|
||||||
|
/// Unacknowledged state to advance.
|
||||||
|
/// </summary>
|
||||||
|
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<ScriptedAlarmDefinition>
|
||||||
|
{
|
||||||
|
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<string, string> 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<CallMethodRequest>
|
||||||
|
{
|
||||||
|
AcknowledgeRequest("al-1.Condition"),
|
||||||
|
};
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { null! };
|
||||||
|
|
||||||
|
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||||
|
MakeIdentity("alice"), calls, results, errors, engine,
|
||||||
|
conditionIdToAlarmId: new Dictionary<string, string>());
|
||||||
|
|
||||||
|
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<CallMethodRequest>
|
||||||
|
{
|
||||||
|
GenericRequest("al-1.Condition"),
|
||||||
|
};
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { 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<CallMethodRequest>
|
||||||
|
{
|
||||||
|
AcknowledgeRequest("al-1.Condition"),
|
||||||
|
};
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var priorError = new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||||
|
var errors = new List<ServiceResult> { 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<CallMethodRequest> { AcknowledgeRequest("al-1.Condition", "looks ok") };
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { 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<CallMethodRequest> { AcknowledgeRequest("al-1.Condition") };
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { 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<CallMethodRequest> { requestNoArgs };
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { 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<CallMethodRequest> { AcknowledgeRequest("al-1.Condition") };
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { 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<ScriptedAlarmDefinition>
|
||||||
|
{
|
||||||
|
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<CallMethodRequest>
|
||||||
|
{
|
||||||
|
ConfirmRequest("confirm-alarm.Condition", "all clear"),
|
||||||
|
};
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { 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<CallMethodRequest>
|
||||||
|
{
|
||||||
|
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<ServiceResult> { 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<CallMethodRequest>
|
||||||
|
{
|
||||||
|
AcknowledgeRequest("al-999.Condition"),
|
||||||
|
};
|
||||||
|
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||||
|
var errors = new List<ServiceResult> { 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 ------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple <see cref="UserIdentity"/> with a display name for unit testing.
|
||||||
|
/// Uses the <c>UserIdentity(username, password)</c> constructor so the base-class
|
||||||
|
/// <see cref="UserIdentity.DisplayName"/> property returns the supplied name when
|
||||||
|
/// accessed through the <see cref="IUserIdentity"/> interface.
|
||||||
|
/// The real production identity is <c>OtOpcUaServer.RoleBasedIdentity</c> which
|
||||||
|
/// populates DisplayName from the LDAP authentication result.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class NamedUserIdentity(string displayName) : UserIdentity(displayName, "") { }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user