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:
@@ -138,9 +138,9 @@ All three are verified closed in the 2026-04-23 exit-gate audit:
|
|||||||
|
|
||||||
These are real open items, not issues with the plan reconciliation.
|
These are real open items, not issues with the plan reconciliation.
|
||||||
|
|
||||||
### Gap 1 — OPC UA method-call dispatch for scripted alarm Ack/Confirm/Shelve (Stream G / C.6)
|
### Gap 1 — OPC UA method-call dispatch for scripted alarm AddComment (Stream G / C.6)
|
||||||
|
|
||||||
`DriverNodeManager.MethodCall` does not route OPC UA `Acknowledge` / `Confirm` / `OneShotShelve` / `TimedShelve` / `Unshelve` / `AddComment` method invocations to the `ScriptedAlarmEngine`. Operators can acknowledge scripted alarms through the Admin UI today; OPC UA HMI clients expecting to use Part 9 method nodes directly cannot. Explicit in `phase-7-e2e-smoke.md` §"Known limitations".
|
`Acknowledge` / `Confirm` route to the `ScriptedAlarmEngine` via `DriverNodeManager.RouteScriptedAlarmMethodCalls` (task #24). `OneShotShelve` / `TimedShelve` / `Unshelve` route via the native `AlarmConditionState.OnShelve` / `OnTimedUnshelve` hooks wired in `MarkAsAlarmCondition` (task #24 follow-up); the per-instance shelve method NodeIds are indexed so the Call gate resolves them to `OpcUaOperation.AlarmShelve`. Only `AddComment` is still not wired to the OPC UA method path — the engine has `AddCommentAsync` but no Part 9 `AddComment` method node is dispatched to it.
|
||||||
|
|
||||||
### Gap 2 — Admin UI: no `/virtual-tags` tab or form (Stream F.2)
|
### Gap 2 — Admin UI: no `/virtual-tags` tab or form (Stream F.2)
|
||||||
|
|
||||||
|
|||||||
50
looseends.md
Normal file
50
looseends.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Loose ends
|
||||||
|
|
||||||
|
State as of 2026-05-18, after the #9–#29 task-list run. Everything on the
|
||||||
|
formal task list is shipped except #20; the items below are what genuinely
|
||||||
|
remains, plus follow-ups surfaced during the run.
|
||||||
|
|
||||||
|
## Open task
|
||||||
|
|
||||||
|
- **#20 — D.1 dev-rig rollout smoke.** A full 3-service deployment
|
||||||
|
(gateway + worker + server + Wonderware historian sidecar): deploy the
|
||||||
|
refreshed binaries, run `scripts/install/Refresh-Services.ps1`, exercise
|
||||||
|
alarms end-to-end, and capture the rollout artifact. The code blockers
|
||||||
|
were cleared by #18; the act itself needs the physical AVEVA dev rig and
|
||||||
|
cannot be produced from a dev box. Runbook context in
|
||||||
|
`docs/plans/alarms-worker-wiring-plan.md`.
|
||||||
|
|
||||||
|
## Follow-ups surfaced during the run
|
||||||
|
|
||||||
|
- **C.1 live SDK binding.** `SdkAlarmHistorianWriteBackend.WriteBatchAsync`
|
||||||
|
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Backend/`) is
|
||||||
|
still a placeholder returning `RetryPlease` for every event, so queued
|
||||||
|
alarm events are retained rather than written. Pinning the real
|
||||||
|
`aahClientManaged` alarm-write entry point is rig-gated — pairs with #20.
|
||||||
|
|
||||||
|
- **~~#24 Shelve-method routing.~~** DONE. Acknowledge / Confirm already
|
||||||
|
routed; OneShotShelve / TimedShelve / Unshelve now route via the native
|
||||||
|
`AlarmConditionState.OnShelve` / `OnTimedUnshelve` hooks wired in
|
||||||
|
`DriverNodeManager.MarkAsAlarmCondition` (scripted alarms get a shelvable
|
||||||
|
`ShelvedStateMachine` subtree created before `alarm.Create`). The three
|
||||||
|
per-instance shelve method NodeIds are indexed so the Call gate resolves
|
||||||
|
them to `OpcUaOperation.AlarmShelve`. Remaining: address-space
|
||||||
|
materialisation of the shelve method nodes is best confirmed by a live
|
||||||
|
OPC UA browse (pairs with the G6 / D.1 rig steps). `AddComment` is still
|
||||||
|
not wired to an OPC UA method node — see `phase-7-status.md` Gap 1.
|
||||||
|
|
||||||
|
- **mxaccessgw alarm epic branch.** The alarm subsystem work (A.2/A.3/A.4
|
||||||
|
+ the two production-gap fixes from #18) lives on the mxaccessgw branch
|
||||||
|
`docs/alarm-client-wm-app-finding`. It is NOT merged to mxaccessgw's main.
|
||||||
|
Whether/when to merge the alarm epic to main is an open release decision.
|
||||||
|
|
||||||
|
- **#15 operator/lab GA gates.** Two v2 GA gates are manual lab steps, not
|
||||||
|
automatable here: the OPC UA CTT (Compliance Test Tool) pass and the
|
||||||
|
deployment-checklist signoff. Documented in
|
||||||
|
`docs/plans/v2-ga-lab-gates-plan.md`.
|
||||||
|
|
||||||
|
## Done — for reference
|
||||||
|
|
||||||
|
The 5 Phase 7 gaps discovered mid-run (#24–#28) were all completed and
|
||||||
|
merged; no Phase 7 gaps remain open. Add any new follow-ups above as they
|
||||||
|
are spun out.
|
||||||
@@ -125,6 +125,16 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
private readonly Dictionary<string, string> _scriptedAlarmIdByConditionNodeId
|
private readonly Dictionary<string, string> _scriptedAlarmIdByConditionNodeId
|
||||||
= new(StringComparer.OrdinalIgnoreCase);
|
= 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,
|
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,
|
||||||
@@ -621,7 +631,8 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
IList<CallMethodResult> results,
|
IList<CallMethodResult> results,
|
||||||
IList<ServiceResult> errors)
|
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
|
// Task #24 — Phase 7 Gap 1: route Part 9 Acknowledge / Confirm calls that target
|
||||||
// scripted alarm condition nodes directly to the ScriptedAlarmEngine. The engine
|
// scripted alarm condition nodes directly to the ScriptedAlarmEngine. The engine
|
||||||
@@ -674,8 +685,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
ScriptedAlarmEngine engine,
|
ScriptedAlarmEngine engine,
|
||||||
IReadOnlyDictionary<string, string> conditionIdToAlarmId)
|
IReadOnlyDictionary<string, string> conditionIdToAlarmId)
|
||||||
{
|
{
|
||||||
var user = userIdentity?.DisplayName;
|
var user = ResolveCallUser(userIdentity);
|
||||||
if (string.IsNullOrWhiteSpace(user)) user = "opcua-client";
|
|
||||||
|
|
||||||
for (var i = 0; i < methodsToCall.Count; i++)
|
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>
|
/// <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"/>
|
||||||
@@ -742,7 +860,8 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
IList<ServiceResult> errors,
|
IList<ServiceResult> errors,
|
||||||
IUserIdentity? userIdentity,
|
IUserIdentity? userIdentity,
|
||||||
AuthorizationGate? gate,
|
AuthorizationGate? gate,
|
||||||
NodeScopeResolver? scopeResolver)
|
NodeScopeResolver? scopeResolver,
|
||||||
|
IReadOnlySet<NodeId>? shelveMethodIds = null)
|
||||||
{
|
{
|
||||||
if (gate is null || scopeResolver is null) return;
|
if (gate is null || scopeResolver is null) return;
|
||||||
if (methodsToCall.Count == 0) 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;
|
if (request.ObjectId.Identifier is not string fullRef) continue;
|
||||||
|
|
||||||
var scope = scopeResolver.Resolve(fullRef);
|
var scope = scopeResolver.Resolve(fullRef);
|
||||||
var operation = MapCallOperation(request.MethodId);
|
var operation = MapCallOperation(request.MethodId, shelveMethodIds);
|
||||||
if (!gate.IsAllowed(userIdentity, operation, scope))
|
if (!gate.IsAllowed(userIdentity, operation, scope))
|
||||||
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
|
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
|
/// operator-UI grants can distinguish acknowledge/confirm/shelve; everything else
|
||||||
/// falls through to generic <see cref="OpcUaOperation.Call"/>.
|
/// falls through to generic <see cref="OpcUaOperation.Call"/>.
|
||||||
/// </summary>
|
/// </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
|
// 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
|
// as ns=0 numeric ids; comparisons are value-based.
|
||||||
// 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.
|
|
||||||
if (methodId == MethodIds.AcknowledgeableConditionType_Acknowledge)
|
if (methodId == MethodIds.AcknowledgeableConditionType_Acknowledge)
|
||||||
return OpcUaOperation.AlarmAcknowledge;
|
return OpcUaOperation.AlarmAcknowledge;
|
||||||
if (methodId == MethodIds.AcknowledgeableConditionType_Confirm)
|
if (methodId == MethodIds.AcknowledgeableConditionType_Confirm)
|
||||||
return OpcUaOperation.AlarmConfirm;
|
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;
|
return OpcUaOperation.Call;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -910,6 +1037,31 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
|
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
|
||||||
DisplayName = new LocalizedText(info.SourceName),
|
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
|
// assignNodeIds=true makes the stack allocate NodeIds for every inherited
|
||||||
// AlarmConditionState child (Severity / Message / ActiveState / AckedState /
|
// AlarmConditionState child (Severity / Message / ActiveState / AckedState /
|
||||||
// EnabledState / …). Without this the children keep Foundation (ns=0) type-
|
// 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
|
// The condition's string identifier is "{FullReference}.Condition"; the engine
|
||||||
// addresses alarms by ScriptedAlarmId (= FullReference for scripted alarms,
|
// addresses alarms by ScriptedAlarmId (= FullReference for scripted alarms,
|
||||||
// because EquipmentNodeWalker sets FullName = ScriptedAlarmId on the attr).
|
// because EquipmentNodeWalker sets FullName = ScriptedAlarmId on the attr).
|
||||||
if (_owner._scriptedAlarmEngine is not null
|
if (isScriptedAlarm)
|
||||||
&& _owner._sourceByFullRef.TryGetValue(FullReference, out var varSource)
|
|
||||||
&& varSource == NodeSourceKind.ScriptedAlarm)
|
|
||||||
{
|
{
|
||||||
var conditionKey = alarm.NodeId.Identifier?.ToString();
|
var conditionKey = alarm.NodeId.Identifier?.ToString();
|
||||||
if (!string.IsNullOrEmpty(conditionKey))
|
if (!string.IsNullOrEmpty(conditionKey))
|
||||||
_owner._scriptedAlarmIdByConditionNodeId[conditionKey!] = FullReference;
|
_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
|
// 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);
|
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)
|
private sealed class ConditionSink(DriverNodeManager owner, OpcAlarmConditionState alarm)
|
||||||
|
|||||||
@@ -41,6 +41,69 @@ public sealed class CallGatingTests
|
|||||||
.ShouldBe(OpcUaOperation.Call);
|
.ShouldBe(OpcUaOperation.Call);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapCallOperation_shelve_method_in_index_maps_to_AlarmShelve()
|
||||||
|
{
|
||||||
|
// Shelve methods carry per-instance NodeIds; membership in the indexed set
|
||||||
|
// (built during the address-space build) is how they resolve to AlarmShelve.
|
||||||
|
var shelveMethodId = new NodeId("al-1.Condition.ShelvingState.OneShotShelve", 2);
|
||||||
|
var index = new HashSet<NodeId> { shelveMethodId };
|
||||||
|
|
||||||
|
DriverNodeManager.MapCallOperation(shelveMethodId, index)
|
||||||
|
.ShouldBe(OpcUaOperation.AlarmShelve);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapCallOperation_shelve_method_not_in_index_falls_through_to_Call()
|
||||||
|
{
|
||||||
|
// A shelve-shaped NodeId that wasn't indexed (e.g. no scripted alarms) is
|
||||||
|
// indistinguishable from a generic method node and gates as Call.
|
||||||
|
var shelveMethodId = new NodeId("al-1.Condition.ShelvingState.OneShotShelve", 2);
|
||||||
|
|
||||||
|
DriverNodeManager.MapCallOperation(shelveMethodId, new HashSet<NodeId>())
|
||||||
|
.ShouldBe(OpcUaOperation.Call);
|
||||||
|
DriverNodeManager.MapCallOperation(shelveMethodId, shelveMethodIds: null)
|
||||||
|
.ShouldBe(OpcUaOperation.Call);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Denied_shelve_call_gets_BadUserAccessDenied()
|
||||||
|
{
|
||||||
|
var shelveMethodId = new NodeId("c1/area/line/eq/alarm1.Condition.ShelvingState.OneShotShelve", 2);
|
||||||
|
var calls = new List<CallMethodRequest>
|
||||||
|
{
|
||||||
|
NewCall("c1/area/line/eq/alarm1", shelveMethodId),
|
||||||
|
};
|
||||||
|
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||||
|
// Operator has AlarmAcknowledge but NOT AlarmShelve — shelve must be denied.
|
||||||
|
var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.AlarmAcknowledge)]);
|
||||||
|
|
||||||
|
DriverNodeManager.GateCallMethodRequests(
|
||||||
|
calls, errors, NewIdentity("alice", "grp-ops"), gate, new NodeScopeResolver("c1"),
|
||||||
|
shelveMethodIds: new HashSet<NodeId> { shelveMethodId });
|
||||||
|
|
||||||
|
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
|
||||||
|
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Allowed_shelve_call_passes_through()
|
||||||
|
{
|
||||||
|
var shelveMethodId = new NodeId("c1/area/line/eq/alarm1.Condition.ShelvingState.OneShotShelve", 2);
|
||||||
|
var calls = new List<CallMethodRequest>
|
||||||
|
{
|
||||||
|
NewCall("c1/area/line/eq/alarm1", shelveMethodId),
|
||||||
|
};
|
||||||
|
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||||
|
var gate = MakeGate(strict: true, rows: [Row("grp-eng", NodePermissions.AlarmShelve)]);
|
||||||
|
|
||||||
|
DriverNodeManager.GateCallMethodRequests(
|
||||||
|
calls, errors, NewIdentity("alice", "grp-eng"), gate, new NodeScopeResolver("c1"),
|
||||||
|
shelveMethodIds: new HashSet<NodeId> { shelveMethodId });
|
||||||
|
|
||||||
|
errors[0].ShouldBeNull("AlarmShelve grant allows the shelve call");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Gate_null_leaves_errors_untouched()
|
public void Gate_null_leaves_errors_untouched()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
|||||||
/// <item>Lax-mode fall-through for all four deferred gates</item>
|
/// <item>Lax-mode fall-through for all four deferred gates</item>
|
||||||
/// <item>Permission-bit isolation — Subscribe-only grant denies Read; HistoryRead-only
|
/// <item>Permission-bit isolation — Subscribe-only grant denies Read; HistoryRead-only
|
||||||
/// grant denies Read (Phase 6.2 compliance item "HistoryRead uses its own flag")</item>
|
/// grant denies Read (Phase 6.2 compliance item "HistoryRead uses its own flag")</item>
|
||||||
/// <item>AlarmShelve intentional fall-through to Call (documents the ShelvedStateMachine
|
/// <item>AlarmShelve resolves via the indexed shelve-method NodeId set (Task #24
|
||||||
/// per-instance NodeId limitation noted in the MapCallOperation implementation)</item>
|
/// follow-up); an unindexed shelve-shaped NodeId still falls through to Call</item>
|
||||||
/// <item>Complete OpcUaOperation → NodePermissions mapping coverage for deferred ops</item>
|
/// <item>Complete OpcUaOperation → NodePermissions mapping coverage for deferred ops</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -203,29 +203,38 @@ public sealed class DeferredGateHardeningTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ======================================================================
|
// ======================================================================
|
||||||
// 5. AlarmShelve falls through to Call in MapCallOperation
|
// 5. AlarmShelve resolution in MapCallOperation (Task #24 follow-up)
|
||||||
// Documents the ShelvedStateMachine per-instance NodeId limitation.
|
// Shelve methods carry per-instance NodeIds, so they resolve to AlarmShelve
|
||||||
|
// via membership in the indexed shelve-method set rather than a constant match.
|
||||||
// ======================================================================
|
// ======================================================================
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MapCallOperation_AlarmShelve_falls_through_to_Call()
|
public void MapCallOperation_indexed_shelve_method_maps_to_AlarmShelve()
|
||||||
{
|
{
|
||||||
// AlarmShelve methods on ShelvedStateMachine arrive with per-instance NodeIds
|
// The address-space build indexes each scripted alarm's three ShelvedStateMachine
|
||||||
// (not well-known type NodeIds), so they can't be reliably constant-matched.
|
// method NodeIds. A call whose MethodId is in that set gates as AlarmShelve, so
|
||||||
// MapCallOperation returns OpcUaOperation.Call for any unrecognised method NodeId;
|
// operators can be granted shelve rights independently of generic MethodCall.
|
||||||
// operators who can Shelve must therefore have NodePermissions.MethodCall granted.
|
var shelveMethodId = new NodeId("al-1.Condition.ShelvingState.OneShotShelve", 2);
|
||||||
// (This is an intentional design decision documented in the MapCallOperation
|
var index = new HashSet<NodeId> { shelveMethodId };
|
||||||
// implementation remarks — finer-grained AlarmShelve gating is deferred until
|
|
||||||
// the method-invocation path also carries a "method-role" annotation.)
|
DriverNodeManager.MapCallOperation(shelveMethodId, index).ShouldBe(OpcUaOperation.AlarmShelve);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapCallOperation_unindexed_shelve_method_falls_through_to_Call()
|
||||||
|
{
|
||||||
|
// Without the index (e.g. a deployment with no scripted alarms) a shelve-shaped
|
||||||
|
// NodeId is indistinguishable from a generic driver method and gates as Call.
|
||||||
var shelveMethodId = new NodeId("ShelvedStateMachine.OneShotShelve", namespaceIndex: 0);
|
var shelveMethodId = new NodeId("ShelvedStateMachine.OneShotShelve", namespaceIndex: 0);
|
||||||
DriverNodeManager.MapCallOperation(shelveMethodId).ShouldBe(OpcUaOperation.Call);
|
DriverNodeManager.MapCallOperation(shelveMethodId).ShouldBe(OpcUaOperation.Call);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MethodCall_grant_allows_generic_Call_including_shelve_path()
|
public void MethodCall_grant_allows_generic_Call()
|
||||||
{
|
{
|
||||||
// Users with MethodCall permission can invoke shelve methods because the gate
|
// Users with MethodCall permission can invoke generic (non-alarm) driver methods.
|
||||||
// maps AlarmShelve back to Call (see MapCallOperation_AlarmShelve_falls_through_to_Call).
|
// Shelve methods now gate as AlarmShelve when indexed (see
|
||||||
|
// MapCallOperation_indexed_shelve_method_maps_to_AlarmShelve).
|
||||||
var gate = MakeGate(strict: true, rows:
|
var gate = MakeGate(strict: true, rows:
|
||||||
[
|
[
|
||||||
Row("grp-eng", NodePermissions.MethodCall),
|
Row("grp-eng", NodePermissions.MethodCall),
|
||||||
|
|||||||
@@ -410,6 +410,76 @@ public sealed class ScriptedAlarmMethodRoutingTests
|
|||||||
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Shelve routing (Task #24 follow-up) -------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvokeEngineShelve_oneshot_shelves_engine_state()
|
||||||
|
{
|
||||||
|
using var engine = BuildEngine("al-1");
|
||||||
|
|
||||||
|
var result = DriverNodeManager.InvokeEngineShelve(
|
||||||
|
engine, "al-1", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null);
|
||||||
|
|
||||||
|
ServiceResult.IsBad(result).ShouldBeFalse("OneShotShelve succeeds");
|
||||||
|
engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.OneShot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvokeEngineShelve_timed_shelves_engine_state()
|
||||||
|
{
|
||||||
|
using var engine = BuildEngine("al-1");
|
||||||
|
|
||||||
|
// shelvingTime is a Duration in ms — InvokeEngineShelve adds it to UtcNow.
|
||||||
|
var result = DriverNodeManager.InvokeEngineShelve(
|
||||||
|
engine, "al-1", "ops-user", shelving: true, oneShot: false, shelvingTime: 60_000, logger: null);
|
||||||
|
|
||||||
|
ServiceResult.IsBad(result).ShouldBeFalse("TimedShelve succeeds");
|
||||||
|
var state = engine.GetState("al-1")!;
|
||||||
|
state.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
|
||||||
|
state.Shelving.UnshelveAtUtc.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvokeEngineShelve_unshelve_clears_engine_state()
|
||||||
|
{
|
||||||
|
using var engine = BuildEngine("al-1");
|
||||||
|
DriverNodeManager.InvokeEngineShelve(
|
||||||
|
engine, "al-1", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null);
|
||||||
|
engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.OneShot);
|
||||||
|
|
||||||
|
var result = DriverNodeManager.InvokeEngineShelve(
|
||||||
|
engine, "al-1", "ops-user", shelving: false, oneShot: false, shelvingTime: 0, logger: null);
|
||||||
|
|
||||||
|
ServiceResult.IsBad(result).ShouldBeFalse("Unshelve succeeds");
|
||||||
|
engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvokeEngineShelve_timed_with_non_positive_duration_returns_BadInvalidArgument()
|
||||||
|
{
|
||||||
|
using var engine = BuildEngine("al-1");
|
||||||
|
|
||||||
|
// A TimedShelve resolving to an unshelve time at-or-before now is rejected by the
|
||||||
|
// engine's Part 9 state machine (ArgumentOutOfRangeException → BadInvalidArgument).
|
||||||
|
var result = DriverNodeManager.InvokeEngineShelve(
|
||||||
|
engine, "al-1", "ops-user", shelving: true, oneShot: false, shelvingTime: 0, logger: null);
|
||||||
|
|
||||||
|
ServiceResult.IsBad(result).ShouldBeTrue();
|
||||||
|
result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvokeEngineShelve_unknown_alarm_returns_BadInvalidArgument()
|
||||||
|
{
|
||||||
|
using var engine = BuildEngine("al-1");
|
||||||
|
|
||||||
|
var result = DriverNodeManager.InvokeEngineShelve(
|
||||||
|
engine, "not-an-alarm", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null);
|
||||||
|
|
||||||
|
ServiceResult.IsBad(result).ShouldBeTrue("unknown alarm id → error result");
|
||||||
|
result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Phase7ComposedSources helpers -------------------------------------
|
// ---- Phase7ComposedSources helpers -------------------------------------
|
||||||
|
|
||||||
private static Script ScriptRow(string id, string source) => new()
|
private static Script ScriptRow(string id, string source) => new()
|
||||||
|
|||||||
Reference in New Issue
Block a user