Task #219 follow-up — close AlarmConditionState child-NodeId + event-propagation gaps
PR #197 surfaced two integration-level wiring gaps in DriverNodeManager's MarkAsAlarmCondition path; this commit fixes both and upgrades the integration test to assert them end-to-end. Fix 1 — addressable child nodes: AlarmConditionState inherits ~50 typed children (Severity / Message / ActiveState / AckedState / EnabledState / …). The stack was leaving them with Foundation-namespace NodeIds (type-declaration defaults) or shared ns=0 counter allocations, so client Read on a child returned BadNodeIdUnknown. Pass assignNodeIds=true to alarm.Create, then walk the condition subtree and rewrite each descendant's NodeId symbolically as {condition-full-ref}.{symbolic-path} in the node manager's namespace. Stable, unique, and collision-free across multiple alarm instances in the same driver. Fix 2 — event propagation to Server.EventNotifier: OPC UA Part 9 event propagation relies on the alarm condition being reachable from Objects/Server via HasNotifier. Call CustomNodeManager2.AddRootNotifier(alarm) after registering the condition so subscriptions placed on Server-object EventNotifier receive the ReportEvent calls ConditionSink emits per-transition. Test upgrades in AlarmSubscribeIntegrationTests: - Driver_alarm_transition_updates_server_side_AlarmConditionState_node — now asserts Severity == 700, Message text, and ActiveState.Id == true through the OPC UA client (previously scoped out as BadNodeIdUnknown). - New: Driver_alarm_event_flows_to_client_subscription_on_Server_EventNotifier subscribes an OPC UA event monitor on ObjectIds.Server, fires a driver transition, and waits for the AlarmConditionType event to be delivered, asserting Message + Severity fields. Previously scoped out as "Part 9 event propagation out of reach." Regression checks: 239 server tests pass (+1 new event-subscription test), 195 Core tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -371,7 +371,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
|
||||
DisplayName = new LocalizedText(info.SourceName),
|
||||
};
|
||||
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, false);
|
||||
// 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-
|
||||
// declaration NodeIds that aren't in the node manager's predefined-node index.
|
||||
// The newly-allocated NodeIds default to ns=0 via the shared identifier
|
||||
// counter — we remap them to the node manager's namespace below so client
|
||||
// Read/Browse on children resolves against the predefined-node dictionary.
|
||||
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, true);
|
||||
// Assign every descendant a stable, collision-free NodeId in the node manager's
|
||||
// namespace keyed on the condition path. The stack's default assignNodeIds path
|
||||
// allocates from a shared ns=0 counter and does not update parent→child
|
||||
// references when we remap, so we do the rename up front, symbolically:
|
||||
// {condition-full-ref}/{symbolic-path-under-condition}
|
||||
AssignSymbolicDescendantIds(alarm, alarm.NodeId, _owner.NamespaceIndex);
|
||||
alarm.SourceName.Value = info.SourceName;
|
||||
alarm.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
|
||||
alarm.Message.Value = new LocalizedText(info.InitialDescription ?? info.SourceName);
|
||||
@@ -382,10 +395,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
alarm.AckedState.Id.Value = true;
|
||||
alarm.ActiveState.Value = new LocalizedText("Inactive");
|
||||
alarm.ActiveState.Id.Value = false;
|
||||
// Enable ConditionRefresh support so clients that connect *after* a transition
|
||||
// can pull the current retained-condition snapshot.
|
||||
alarm.ClientUserId.Value = string.Empty;
|
||||
alarm.BranchId.Value = NodeId.Null;
|
||||
|
||||
_variable.AddChild(alarm);
|
||||
_owner.AddPredefinedNode(_owner.SystemContext, alarm);
|
||||
|
||||
// Part 9 event propagation: AddRootNotifier registers the alarm as an event
|
||||
// source reachable from Objects/Server so subscriptions placed on Server-object
|
||||
// EventNotifier receive the ReportEvent calls ConditionSink.OnTransition emits.
|
||||
// Without this the Report fires but has no subscribers to deliver to.
|
||||
_owner.AddRootNotifier(alarm);
|
||||
|
||||
return new ConditionSink(_owner, alarm);
|
||||
}
|
||||
}
|
||||
@@ -398,6 +421,26 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
AlarmSeverity.Critical => 900,
|
||||
_ => 500,
|
||||
};
|
||||
|
||||
// After alarm.Create(assignNodeIds=true), every descendant has *some* NodeId but
|
||||
// they default to ns=0 via the shared identifier counter — allocations from two
|
||||
// different alarms collide when we move them into the driver's namespace. Rewriting
|
||||
// symbolically based on the condition path gives each descendant a unique, stable
|
||||
// NodeId in the node manager's namespace. Browse + Read resolve against the current
|
||||
// NodeId because the stack's CustomNodeManager2.Browse traverses NodeState.Children
|
||||
// (NodeState references) and uses each child's current .NodeId in the response.
|
||||
private static void AssignSymbolicDescendantIds(
|
||||
NodeState parent, NodeId parentNodeId, ushort namespaceIndex)
|
||||
{
|
||||
var children = new List<BaseInstanceState>();
|
||||
parent.GetChildren(null!, children);
|
||||
foreach (var child in children)
|
||||
{
|
||||
child.NodeId = new NodeId(
|
||||
$"{parentNodeId.Identifier}.{child.SymbolicName}", namespaceIndex);
|
||||
AssignSymbolicDescendantIds(child, child.NodeId, namespaceIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
|
||||
|
||||
Reference in New Issue
Block a user