feat(server): route OPC UA Part 9 shelve methods to ScriptedAlarmEngine (#24)

OneShotShelve / TimedShelve / Unshelve now reach the ScriptedAlarmEngine.
Scripted-alarm condition nodes get a ShelvedStateMachine subtree created
before alarm.Create so the stack wires each shelve method's dispatch
handler; AlarmConditionState.OnShelve / OnTimedUnshelve route to the
engine and mirror the result onto the OPC UA node via SetShelvingState.

The three per-instance shelve method NodeIds are indexed so the Call gate
resolves them to OpcUaOperation.AlarmShelve instead of falling through to
generic Call. Engine dispatch is split into the node-free InvokeEngineShelve
so the routing decision is unit-testable.

Adds 9 unit tests; updates phase-7-status.md Gap 1 (only AddComment remains
unwired) and the #24 entry in looseends.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 09:31:30 -04:00
parent 56bb1ceaf5
commit c5915700bd
6 changed files with 421 additions and 33 deletions

View File

@@ -125,6 +125,16 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private readonly Dictionary<string, string> _scriptedAlarmIdByConditionNodeId
= new(StringComparer.OrdinalIgnoreCase);
// Task #24 follow-up — NodeIds of the OneShotShelve / TimedShelve / Unshelve method
// nodes created on scripted-alarm ShelvedStateMachine subtrees. Those methods carry
// per-instance NodeIds (not well-known type MethodIds), so the Call gate can't
// constant-match them; it consults this set instead to map a shelve invocation to
// OpcUaOperation.AlarmShelve. Routing itself is handled by the native
// AlarmConditionState.OnShelve hook wired in MarkAsAlarmCondition — no Call-override
// interception is needed because the stack dispatches the method to that delegate.
// Populated during the address-space build; read-only once clients are served.
private readonly HashSet<NodeId> _scriptedAlarmShelveMethodNodeIds = new();
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
@@ -621,7 +631,8 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
IList<CallMethodResult> results,
IList<ServiceResult> errors)
{
GateCallMethodRequests(methodsToCall, errors, context.UserIdentity, _authzGate, _scopeResolver);
GateCallMethodRequests(methodsToCall, errors, context.UserIdentity, _authzGate, _scopeResolver,
_scriptedAlarmShelveMethodNodeIds);
// Task #24 — Phase 7 Gap 1: route Part 9 Acknowledge / Confirm calls that target
// scripted alarm condition nodes directly to the ScriptedAlarmEngine. The engine
@@ -674,8 +685,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
ScriptedAlarmEngine engine,
IReadOnlyDictionary<string, string> conditionIdToAlarmId)
{
var user = userIdentity?.DisplayName;
if (string.IsNullOrWhiteSpace(user)) user = "opcua-client";
var user = ResolveCallUser(userIdentity);
for (var i = 0; i < methodsToCall.Count; i++)
{
@@ -732,6 +742,114 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
}
}
/// <summary>
/// Resolves the audit identity for an OPC UA method call. Authenticated LDAP
/// sessions populate <see cref="IUserIdentity.DisplayName"/> during
/// <c>OtOpcUaServer.OnImpersonateUser</c>; anonymous sessions fall back to
/// <c>"opcua-client"</c> so every audit entry carries an identity.
/// </summary>
internal static string ResolveCallUser(IUserIdentity? userIdentity)
{
var user = userIdentity?.DisplayName;
return string.IsNullOrWhiteSpace(user) ? "opcua-client" : user;
}
/// <summary>
/// Task #24 follow-up — native <c>AlarmConditionState.OnShelve</c> handler for a
/// scripted alarm. The OPC UA stack dispatches OneShotShelve / TimedShelve /
/// Unshelve method calls here after validating the Part 9 state transition. The
/// handler advances the <see cref="ScriptedAlarmEngine"/> with the authenticated
/// principal, then mirrors the new shelving state onto the OPC UA node via
/// <c>SetShelvingState</c>. A failed engine call returns a Bad status so the stack
/// leaves the node's <c>ShelvedStateMachine</c> unchanged.
/// </summary>
internal static ServiceResult RouteScriptedAlarmShelve(
ISystemContext context,
OpcAlarmConditionState alarm,
bool shelving,
bool oneShot,
double shelvingTime,
ScriptedAlarmEngine engine,
string alarmId,
ILogger? logger)
{
var user = ResolveCallUser(context?.UserIdentity);
var engineResult = InvokeEngineShelve(engine, alarmId, user, shelving, oneShot, shelvingTime, logger);
if (ServiceResult.IsBad(engineResult)) return engineResult;
// Mirror the engine's new state onto the OPC UA ShelvedStateMachine. The stack
// expects the OnShelve handler to advance the node — it does not do so itself.
alarm?.SetShelvingState(context, shelving, oneShot, shelvingTime);
return ServiceResult.Good;
}
/// <summary>
/// Task #24 follow-up — native <c>AlarmConditionState.OnTimedUnshelve</c> handler:
/// the stack's timed-shelve countdown has expired, so unshelve the alarm in the
/// engine and mirror the Unshelved state onto the OPC UA node.
/// </summary>
internal static ServiceResult RouteScriptedAlarmTimedUnshelve(
ISystemContext context,
OpcAlarmConditionState alarm,
ScriptedAlarmEngine engine,
string alarmId,
ILogger? logger)
{
// The expiry is a server-side timer, not an operator action — attribute the
// audit entry to the subsystem rather than a user principal.
var engineResult = InvokeEngineShelve(
engine, alarmId, "timed-unshelve", shelving: false, oneShot: false, shelvingTime: 0, logger);
if (ServiceResult.IsBad(engineResult)) return engineResult;
alarm?.SetShelvingState(context, false, false, 0);
return ServiceResult.Good;
}
/// <summary>
/// Dispatches a shelve transition to the <see cref="ScriptedAlarmEngine"/>. Extracted
/// as a pure function (no OPC UA node dependency) so the engine-routing decision —
/// including the <see cref="OpcUaOperation"/>-shaped status mapping — is unit-testable.
/// <paramref name="shelving"/> / <paramref name="oneShot"/> follow the OPC UA
/// <c>OnShelve</c> contract: <c>(false, *)</c> = Unshelve, <c>(true, true)</c> =
/// OneShotShelve, <c>(true, false)</c> = TimedShelve for <paramref name="shelvingTime"/>
/// milliseconds.
/// </summary>
internal static ServiceResult InvokeEngineShelve(
ScriptedAlarmEngine engine,
string alarmId,
string user,
bool shelving,
bool oneShot,
double shelvingTime,
ILogger? logger)
{
try
{
if (!shelving)
engine.UnshelveAsync(alarmId, user, CancellationToken.None).GetAwaiter().GetResult();
else if (oneShot)
engine.OneShotShelveAsync(alarmId, user, CancellationToken.None).GetAwaiter().GetResult();
else
engine.TimedShelveAsync(
alarmId, user, DateTime.UtcNow.AddMilliseconds(shelvingTime), CancellationToken.None)
.GetAwaiter().GetResult();
return ServiceResult.Good;
}
catch (ArgumentException ex)
{
// Unknown alarmId or an invalid Part 9 transition — surface as BadInvalidArgument
// so the OPC UA client sees a meaningful status.
logger?.LogInformation(
"Scripted-alarm shelve rejected for {AlarmId}: {Message}", alarmId, ex.Message);
return new ServiceResult(StatusCodes.BadInvalidArgument, ex.Message, ex.Message);
}
catch (Exception ex)
{
logger?.LogError(ex, "Scripted-alarm shelve failed for {AlarmId}", alarmId);
return 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"/>
@@ -742,7 +860,8 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
IList<ServiceResult> errors,
IUserIdentity? userIdentity,
AuthorizationGate? gate,
NodeScopeResolver? scopeResolver)
NodeScopeResolver? scopeResolver,
IReadOnlySet<NodeId>? shelveMethodIds = null)
{
if (gate is null || scopeResolver is null) return;
if (methodsToCall.Count == 0) return;
@@ -755,7 +874,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
if (request.ObjectId.Identifier is not string fullRef) continue;
var scope = scopeResolver.Resolve(fullRef);
var operation = MapCallOperation(request.MethodId);
var operation = MapCallOperation(request.MethodId, shelveMethodIds);
if (!gate.IsAllowed(userIdentity, operation, scope))
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
}
@@ -767,20 +886,28 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
/// operator-UI grants can distinguish acknowledge/confirm/shelve; everything else
/// falls through to generic <see cref="OpcUaOperation.Call"/>.
/// </summary>
internal static OpcUaOperation MapCallOperation(NodeId methodId)
/// <param name="methodId">The <see cref="NodeId"/> of the method being invoked.</param>
/// <param name="shelveMethodIds">
/// The set of per-instance OneShotShelve / TimedShelve / Unshelve method NodeIds
/// indexed during the address-space build (see
/// <c>_scriptedAlarmShelveMethodNodeIds</c>). Shelve methods carry per-instance
/// NodeIds rather than well-known type NodeIds, so they can't be constant-matched
/// like Acknowledge / Confirm; a membership test against this set is how they
/// resolve to <see cref="OpcUaOperation.AlarmShelve"/>. When <c>null</c> (no
/// scripted alarms) shelve methods fall through to <see cref="OpcUaOperation.Call"/>.
/// </param>
internal static OpcUaOperation MapCallOperation(NodeId methodId, IReadOnlySet<NodeId>? shelveMethodIds = null)
{
// Standard Part 9 method ids on AcknowledgeableConditionType. The stack models these
// as ns=0 numeric ids; comparisons are value-based. Shelve is dispatched on the
// ShelvedStateMachine instance's methods — those arrive with per-instance NodeIds
// rather than well-known type NodeIds, so we can't reliably constant-match them
// here. Shelve falls through to OpcUaOperation.Call; the caller can still set a
// permissive Call grant for operators who are allowed to shelve alarms, and
// finer-grained AlarmShelve gating is a follow-up when the method-invocation path
// also carries a "method-role" annotation.
// as ns=0 numeric ids; comparisons are value-based.
if (methodId == MethodIds.AcknowledgeableConditionType_Acknowledge)
return OpcUaOperation.AlarmAcknowledge;
if (methodId == MethodIds.AcknowledgeableConditionType_Confirm)
return OpcUaOperation.AlarmConfirm;
// Shelve methods live on each alarm's own ShelvedStateMachine subtree, so they're
// matched by NodeId membership rather than a constant comparison.
if (methodId is not null && shelveMethodIds is not null && shelveMethodIds.Contains(methodId))
return OpcUaOperation.AlarmShelve;
return OpcUaOperation.Call;
}
@@ -910,6 +1037,31 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
DisplayName = new LocalizedText(info.SourceName),
};
// Task #24 follow-up — scripted alarms expose a shelvable ShelvingState
// subtree so OPC UA Part 9 OneShotShelve / TimedShelve / Unshelve method
// calls have method nodes to target. The optional ShelvingState is NOT
// created by AlarmConditionState.Create; it must be attached *before*
// Create so the stack's AlarmConditionState.OnAfterCreate wires each shelve
// method's OnCallMethod handler to the ShelvedStateMachine. Non-scripted
// alarms (Galaxy etc.) have no engine to route to, so they stay unshelvable.
var isScriptedAlarm =
_owner._scriptedAlarmEngine is not null
&& _owner._sourceByFullRef.TryGetValue(FullReference, out var conditionVarSource)
&& conditionVarSource == NodeSourceKind.ScriptedAlarm;
if (isScriptedAlarm)
{
alarm.ShelvingState = new ShelvedStateMachineState(alarm);
alarm.ShelvingState.Create(
_owner.SystemContext, null,
new QualifiedName(BrowseNames.ShelvingState),
new LocalizedText(BrowseNames.ShelvingState), false);
// UnshelveTime carries the timed-shelve countdown; it is optional and
// not materialised by ShelvedStateMachineState.Create — create it so
// the stack's timed-unshelve timer has a node to write.
alarm.ShelvingState.UnshelveTime ??= new PropertyState<double>(alarm.ShelvingState);
}
// assignNodeIds=true makes the stack allocate NodeIds for every inherited
// AlarmConditionState child (Severity / Message / ActiveState / AckedState /
// EnabledState / …). Without this the children keep Foundation (ns=0) type-
@@ -955,13 +1107,16 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
// 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)
if (isScriptedAlarm)
{
var conditionKey = alarm.NodeId.Identifier?.ToString();
if (!string.IsNullOrEmpty(conditionKey))
_owner._scriptedAlarmIdByConditionNodeId[conditionKey!] = FullReference;
// Task #24 follow-up — wire the shelve methods created above to the
// engine and index their NodeIds for the Call gate.
if (alarm.ShelvingState is not null)
WireScriptedAlarmShelving(alarm, FullReference);
}
// PR 2.3 — when the server-level alarm-condition service is wired, register
@@ -1024,6 +1179,47 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
AssignSymbolicDescendantIds(child, child.NodeId, namespaceIndex);
}
}
/// <summary>
/// Task #24 follow-up — connects a scripted alarm's <c>ShelvingState</c> subtree to
/// the <see cref="ScriptedAlarmEngine"/>. The stack dispatches OneShotShelve /
/// TimedShelve / Unshelve method calls to the <c>OnShelve</c> delegate and the
/// expiry of a timed shelve to <c>OnTimedUnshelve</c>; both routes advance the
/// engine state machine and mirror the result onto the OPC UA node. The three
/// shelve method NodeIds are indexed so the Call gate can resolve them to
/// <see cref="OpcUaOperation.AlarmShelve"/>.
/// </summary>
private void WireScriptedAlarmShelving(OpcAlarmConditionState alarm, string alarmId)
{
var shelving = alarm.ShelvingState!;
var engine = _owner._scriptedAlarmEngine!;
var logger = _owner._logger;
// How often the timed-unshelve countdown ticks toward expiry (milliseconds).
alarm.UnshelveTimeUpdateRate = 1000;
alarm.OnShelve = (context, a, isShelving, oneShot, shelvingTime) =>
RouteScriptedAlarmShelve(context, a, isShelving, oneShot, shelvingTime, engine, alarmId, logger);
alarm.OnTimedUnshelve = (context, a) =>
RouteScriptedAlarmTimedUnshelve(context, a, engine, alarmId, logger);
CollectShelveMethodNodeIds(shelving, _owner._scriptedAlarmShelveMethodNodeIds);
}
/// <summary>
/// Adds the NodeIds of the <c>ShelvedStateMachine</c>'s method children
/// (OneShotShelve / TimedShelve / Unshelve) to <paramref name="sink"/>.
/// <see cref="AssignSymbolicDescendantIds"/> has already given each a stable
/// NodeId in the node manager's namespace by the time this runs.
/// </summary>
private static void CollectShelveMethodNodeIds(ShelvedStateMachineState shelving, HashSet<NodeId> sink)
{
var children = new List<BaseInstanceState>();
shelving.GetChildren(null!, children);
foreach (var child in children)
if (child is MethodState method && !NodeId.IsNull(method.NodeId))
sink.Add(method.NodeId);
}
}
private sealed class ConditionSink(DriverNodeManager owner, OpcAlarmConditionState alarm)