fix(opcua): carry native alarm condition state across a full rebuild (#641)

A full RebuildAddressSpace recreated every native Part 9 condition inactive +
acknowledged, and when the alarm-tag set was unchanged (e.g. one
UnsTagReference added to an alarm-bearing raw tag) nothing re-told the new node
its state: the host does not re-subscribe for an unchanged set, and Galaxy /
OpcUaClient have no snapshot to replay anyway. Active alarms read normal until
their next device transition.

OtOpcUaNodeManager now captures each NATIVE condition's live state (from the
node, so a client-applied ack is kept) before the rebuild clears, restores it
when the same id is re-materialised native in the same apply, and reports ONE
condition event per restored off-normal condition at the new
IOpcUaAddressSpaceSink.CompleteAddressSpaceApply (after notifier wiring).
Unclaimed captures are discarded there, so a later re-add is fresh. Quality is
carried silently for normal conditions too (it is only re-annotated on a
connectivity edge). Scripted conditions are untouched (#487 re-asserts them).
OpcUaPublishActor calls CompleteAddressSpaceApply at the end of every apply.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 17:02:12 -04:00
parent a8b07ae12d
commit ddd7678269
9 changed files with 611 additions and 3 deletions
@@ -80,11 +80,13 @@ public class DeferredSinkForwardingReflectionTests
// v3 B4-WP2 dual-namespace invariant: a node id alone is no longer globally unique across the two
// namespaces (Raw vs UNS), so every sink method that NAMES a node must carry an AddressSpaceRealm
// discriminator — the sink resolves the namespace from the realm rather than parsing the id string.
// RebuildAddressSpace is the sole node-naming-free method (it clears BOTH realms). This guard locks in
// the realm surface so a future method can't be added that names a node without a realm.
// RebuildAddressSpace (it clears BOTH realms) and CompleteAddressSpaceApply (it closes an apply pass,
// Gitea #641) are the only node-naming-free methods. This guard locks in the realm surface so a future
// method can't be added that names a node without a realm.
foreach (var method in ForwardingInterfaces.SelectMany(i => i.GetMethods()))
{
if (method.Name == nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)) continue;
if (method.Name is nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)
or nameof(IOpcUaAddressSpaceSink.CompleteAddressSpaceApply)) continue;
method.GetParameters().Any(p => CarriesRealm(p.ParameterType)).ShouldBeTrue(
$"{method.DeclaringType!.Name}.{method.Name} names a node but has no AddressSpaceRealm parameter — " +