feat(v3-batch4-wp4): multi-notifier native alarms (single ReportEvent → raw + equipment notifiers) + teardown symmetry

Materialize each native alarm ONCE at the raw tag (ConditionId = RawPath, Raw
realm); wire the single condition as an SDK event notifier of each referencing
equipment's UNS folder so one ReportEvent fans to every root without
re-reporting per root (which would break Server-object dedup + Part 9 ack
correlation).

- New sink method WireAlarmNotifiers(alarmNodeId, alarmRealm,
  notifierFolderNodeIds, notifierFolderRealm) on IOpcUaAddressSpaceSink,
  forwarded through DeferredAddressSpaceSink + SdkAddressSpaceSink +
  NullOpcUaAddressSpaceSink (the forwarding-trap guard); auto-covered by the
  DeferredSinkForwardingReflectionTests realm + forwarding guards + a
  hand-written forward test.
- OtOpcUaNodeManager: the normative AddNotifier(isInverse) pair + idempotent
  EnsureFolderIsEventNotifier per equipment folder; tracked per condition in
  _alarmNotifierWiring. Teardown symmetry: RemoveNotifier(bidirectional:true) on
  RebuildAddressSpace, RemoveAlarmConditionNode, and RemoveEquipmentSubtree so no
  inverse-notifier entry leaks across redeploys.
- AddressSpaceApplier.MaterialiseRawSubtree wires notifiers for each native alarm
  tag, resolving its ReferencingEquipmentPaths (Area/Line/Equipment) to the
  EquipmentId folder NodeIds via BuildEquipmentIdByFolderPath.
- AlarmTransitionEvent gains ReferencingEquipmentPaths (empty default); /alerts
  renders the referencing-equipment list as display metadata.
- Un-skipped + rewrote the native-alarm dark tests
  (DriverHostActorNativeAlarmTests x6, DriverHostActorNativeAlarmAckRoutingTests
  x1) for the v3 raw-condition model; new NodeManagerMultiNotifierAlarmTests
  proves multi-notifier wiring + teardown symmetry (no leaked duplicates after a
  re-trip) + applier wiring test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 12:07:11 -04:00
parent 9d09523675
commit 8ebc712eff
21 changed files with 744 additions and 182 deletions
@@ -63,6 +63,25 @@ public class DeferredAddressSpaceSinkTests
inner.RebuildCalled.ShouldBeTrue();
}
[Fact]
public void After_SetSink_WireAlarmNotifiers_is_forwarded_with_all_args()
{
// v3 Batch 4 WP4: without this forward the native-alarm multi-notifier fan-out ships INERT on every
// driver-role host (actors inject THIS wrapper, not the inner sink) — the F10b / PR#423 trap class.
var inner = new SpySink();
var sink = new DeferredAddressSpaceSink();
sink.SetSink(inner);
sink.WireAlarmNotifiers("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw,
new[] { "EQ-1", "EQ-2" }, AddressSpaceRealm.Uns);
inner.WireAlarmNotifiersArgs.ShouldNotBeNull();
inner.WireAlarmNotifiersArgs!.Value.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi");
inner.WireAlarmNotifiersArgs.Value.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw);
inner.WireAlarmNotifiersArgs.Value.Folders.ShouldBe(new[] { "EQ-1", "EQ-2" });
inner.WireAlarmNotifiersArgs.Value.FolderRealm.ShouldBe(AddressSpaceRealm.Uns);
}
// ---------- ISurgicalAddressSpaceSink forwarding ----------
[Fact]
@@ -185,6 +204,7 @@ public class DeferredAddressSpaceSinkTests
{
public bool WriteValueCalled { get; private set; }
public bool RebuildCalled { get; private set; }
public (string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> Folders, AddressSpaceRealm FolderRealm)? WireAlarmNotifiersArgs { get; private set; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> WriteValueCalled = true;
@@ -195,6 +215,8 @@ public class DeferredAddressSpaceSinkTests
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void RebuildAddressSpace() => RebuildCalled = true;
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
=> WireAlarmNotifiersArgs = (alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
@@ -210,6 +232,7 @@ public class DeferredAddressSpaceSinkTests
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)