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
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
/// <param name="AlarmTypeName">OPC UA Part 9 condition subtype name — one of <c>LimitAlarm</c> / <c>DiscreteAlarm</c> / <c>OffNormalAlarm</c> / <c>AlarmCondition</c> (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.</param>
/// <param name="Comment">Operator-supplied comment on ack / confirm / comment transitions; <c>null</c> for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.</param>
/// <param name="HistorizeToAveva">When <c>false</c>, the durable historian sink suppresses this transition (the live <c>alerts</c> fan-out is unaffected); <c>null</c> or <c>true</c> historize. <c>null</c> is the cross-version/rolling-restart case: an old-format message missing the field deserializes to <c>null</c> (CLR default for <c>bool?</c>) and is historized (safe default-on), matching the <c>AlarmTypeName</c> null-coalesce in <c>HistorianAdapterActor.Translate</c>. The producer (<c>ScriptedAlarmHostActor</c>) always sets a concrete <c>true</c>/<c>false</c>.</param>
/// <param name="ReferencingEquipmentPaths">v3 Batch 4 (multi-notifier native alarms) — the (possibly empty) list of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference the alarm's backing raw tag. A native alarm is a SINGLE Part 9 condition materialised once at the raw tag (its <see cref="AlarmId"/> is the RawPath); the same condition fans events to every referencing equipment's UNS folder via SDK notifiers, and this list is carried so <c>/alerts</c> shows the one condition row with all its referencing equipment as display metadata. Empty for scripted alarms (they are per-equipment) and for a native alarm whose raw tag no equipment references. Defaults empty so every existing producer + rolling-restart deserialization keeps compiling / working.</param>
public sealed record AlarmTransitionEvent(
string AlarmId,
string EquipmentPath,
@@ -28,4 +29,5 @@ public sealed record AlarmTransitionEvent(
DateTime TimestampUtc,
string AlarmTypeName = "AlarmCondition",
string? Comment = null,
bool? HistorizeToAveva = null);
bool? HistorizeToAveva = null,
IReadOnlyList<string>? ReferencingEquipmentPaths = null);
@@ -34,6 +34,13 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc />
// Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
// referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
=> _inner.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
@@ -51,6 +51,28 @@ public interface IOpcUaAddressSpaceSink
/// live in.</param>
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false);
/// <summary>
/// v3 Batch 4 (multi-notifier native alarms) — wire an already-materialised native alarm condition
/// (<paramref name="alarmNodeId"/>, materialised at the raw tag via <see cref="MaterialiseAlarmCondition"/>
/// with ConditionId = the RawPath) as an event notifier of EACH referencing equipment's UNS folder, so
/// the condition's <b>single</b> <c>ReportEvent</c> fans one event to every referencing equipment root
/// WITHOUT re-reporting per root (distinct EventIds would break Server-object dedup + Part 9 ack
/// correlation). Per folder the sink wires the SDK's bidirectional notifier pattern
/// (<c>alarm.AddNotifier(isInverse:true, folder)</c> + <c>folder.AddNotifier(isInverse:false, alarm)</c>)
/// and promotes the folder to an event notifier. <b>Idempotent</b> (a re-wire of the same pair updates,
/// never duplicates); a <b>missing endpoint is a no-op</b> (logged, never thrown) so a mid-rebuild race
/// can't fault a deploy. The sink tracks each wired pair so a later rebuild / condition-removal /
/// equipment-subtree-removal tears the notifier down bidirectionally (no inverse-notifier entry leaks
/// across redeploys).
/// </summary>
/// <param name="alarmNodeId">The native alarm condition's node id (== the backing tag's RawPath).</param>
/// <param name="alarmRealm">The namespace realm the condition lives in (Raw for native alarms).</param>
/// <param name="notifierFolderNodeIds">The equipment folder node ids (their <c>s=</c> ids) to wire as
/// extra event-notifier roots for this condition. An unknown / not-yet-materialised folder id is skipped.</param>
/// <param name="notifierFolderRealm">The namespace realm the notifier folders live in (Uns for equipment folders).</param>
void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm);
/// <summary>
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to
/// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
@@ -147,6 +169,9 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <inheritdoc />
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
@@ -64,7 +64,22 @@ else
<tr>
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
<td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
<td><span class="mono small">@e.EquipmentPath</span></td>
<td>
<span class="mono small">@e.EquipmentPath</span>
@* v3 Batch 4 (multi-notifier native alarms): one condition row carries the list
of referencing equipment (Area/Line/Equipment) as display metadata. Shown only
when the producer populated it (native alarms whose raw tag ≥1 equipment
references); scripted alarms + unreferenced native alarms leave it empty. *@
@if (e.ReferencingEquipmentPaths is { Count: > 0 } refs)
{
<div class="text-muted small" title="Referencing equipment">
@foreach (var p in refs)
{
<span class="chip chip-idle" style="font-size:0.72rem; margin:1px">@p</span>
}
</div>
}
</td>
<td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
<td class="num">@e.Severity</td>
<td>@e.User</td>
@@ -687,13 +687,41 @@ public sealed class AddressSpaceApplier
if (!SafeEnsureFolder(c.NodeId, c.ParentNodeId, c.DisplayName, AddressSpaceRealm.Raw)) failed++;
}
// v3 Batch 4 WP4 — reverse map (equipment UNS folder PATH → EquipmentId) so a native alarm's
// ReferencingEquipmentPaths (Area/Line/Equipment name paths, from the composer) resolve to the
// EquipmentId the equipment folders were materialised under by MaterialiseHierarchy. Built lazily only
// when at least one raw tag carries an alarm (the common no-alarm deploy pays nothing).
IReadOnlyDictionary<string, string>? equipIdByFolderPath = null;
foreach (var t in composition.RawTags)
{
if (t.Alarm is not null)
{
// Native alarm tag → a single Part 9 condition node at the RawPath (ConditionId = RawPath).
// Parent is its device/group folder. Multi-notifier fan-out to referencing equipment is WP4.
if (!SafeMaterialiseAlarmCondition(t.NodeId, t.ParentNodeId ?? string.Empty, t.Name, t.Alarm.AlarmType, t.Alarm.Severity, isNative: true, AddressSpaceRealm.Raw)) failed++;
// Parent is its device/group folder. The single condition instance is materialised ONCE here.
if (!SafeMaterialiseAlarmCondition(t.NodeId, t.ParentNodeId ?? string.Empty, t.Name, t.Alarm.AlarmType, t.Alarm.Severity, isNative: true, AddressSpaceRealm.Raw))
{
failed++;
}
else if (t.ReferencingEquipmentPaths.Count > 0)
{
// WP4 multi-notifier fan-out: wire the SINGLE condition as an event notifier of each
// referencing equipment's UNS folder, so one ReportEvent reaches every referencing equipment
// (never re-reported per root). Resolve the folder PATHS to their EquipmentId folder NodeIds
// (the id scheme MaterialiseHierarchy created the equipment folders under).
equipIdByFolderPath ??= BuildEquipmentIdByFolderPath(composition);
var equipFolderNodeIds = t.ReferencingEquipmentPaths
.Select(p => equipIdByFolderPath!.GetValueOrDefault(p))
.Where(id => !string.IsNullOrEmpty(id))
.Select(id => id!)
.Distinct(StringComparer.Ordinal)
.ToList();
if (equipFolderNodeIds.Count > 0 &&
!SafeWireAlarmNotifiers(t.NodeId, AddressSpaceRealm.Raw, equipFolderNodeIds, AddressSpaceRealm.Uns))
{
failed++;
}
}
}
else
{
@@ -1037,6 +1065,49 @@ public sealed class AddressSpaceApplier
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); return false; }
}
/// <summary>Wire a native alarm condition's extra equipment-folder notifiers (WP4 multi-notifier),
/// swallowing (and Warning-logging) any sink fault. Returns <c>true</c> on success, <c>false</c> when the
/// sink threw — callers tally the false into their pass's failed-node count (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the notifiers were wired; <c>false</c> when the sink threw.</returns>
private bool SafeWireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
{
try { _sink.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WireAlarmNotifiers threw for {Node}", alarmNodeId); return false; }
}
/// <summary>
/// v3 Batch 4 WP4 — build the reverse map <c>equipment UNS folder PATH (Area/Line/Equipment) →
/// EquipmentId</c>. The composer emits a native alarm's <see cref="RawTagPlan.ReferencingEquipmentPaths"/>
/// as name paths (<c>V3NodeIds.Uns(areaName, lineName, equipName)</c>), but the equipment folders were
/// materialised under their logical <c>EquipmentId</c> by <see cref="MaterialiseHierarchy"/>, so the
/// multi-notifier wiring must translate path → id. This inverts the composer's own equipment-folder-path
/// construction EXACTLY (area/line/equipment DisplayName == the UNS level Name, so the paths match
/// byte-for-byte); an invalid segment throws in <see cref="V3NodeIds.Uns"/> and is skipped (the same
/// drop the composer applies), so an entry the composer produced always resolves here.
/// </summary>
/// <param name="composition">The composition carrying the UNS topology + equipment nodes.</param>
/// <returns>A map from each resolvable equipment folder path to its EquipmentId.</returns>
private static IReadOnlyDictionary<string, string> BuildEquipmentIdByFolderPath(AddressSpaceComposition composition)
{
var areaName = composition.UnsAreas
.GroupBy(a => a.UnsAreaId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DisplayName, StringComparer.Ordinal);
var lineByid = composition.UnsLines
.GroupBy(l => l.UnsLineId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().DisplayName), StringComparer.Ordinal);
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var e in composition.EquipmentNodes)
{
if (string.IsNullOrWhiteSpace(e.UnsLineId)) continue;
if (!lineByid.TryGetValue(e.UnsLineId, out var line)) continue;
if (!areaName.TryGetValue(line.UnsAreaId, out var aName)) continue;
try { map[V3NodeIds.Uns(aName, line.DisplayName, e.DisplayName)] = e.EquipmentId; }
catch (ArgumentException) { /* invalid segment — dropped, mirroring the composer */ }
}
return map;
}
}
/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.
@@ -71,6 +71,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
/// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
/// <summary>v3 Batch 4 (WP4 multi-notifier native alarms): tracks, per materialised native-alarm condition,
/// the equipment folders wired as EXTRA event-notifier roots for it (via <see cref="WireAlarmNotifiers"/>).
/// A native alarm is a single condition at the raw tag (ConditionId = RawPath); its single
/// <c>ReportEvent</c> fans one event to every referencing equipment's UNS folder through the SDK notifier
/// list (never re-reported per root). Keyed by the condition's full (namespace-qualified) NodeId string
/// (matches <see cref="_alarmConditions"/>' keys). On rebuild / condition-removal / equipment-subtree-removal
/// each wired pair is torn down bidirectionally (<c>RemoveNotifier(bidirectional:true)</c>) so an
/// inverse-notifier entry never leaks across redeploys. Guarded by the same <c>Lock</c> as
/// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
private readonly Dictionary<string, AlarmNotifierWiring> _alarmNotifierWiring = new(StringComparer.Ordinal);
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
@@ -340,6 +350,55 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
internal FolderState? TryGetFolder(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
_folders.TryGetValue(MapKey(realm, nodeId), out var folder) ? folder : null;
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
/// materialised alarm condition <paramref name="alarmNodeId"/> — for a native alarm this is the number of
/// referencing equipment folders wired via <see cref="WireAlarmNotifiers"/>. Zero when the condition is
/// absent. Used by the multi-notifier + teardown-symmetry tests to prove no notifier entry leaks/duplicates
/// across redeploys.</summary>
/// <param name="alarmNodeId">The alarm condition node identifier.</param>
/// <param name="realm">The realm the condition lives in (Raw for native alarms).</param>
/// <returns>The number of notifier entries on the condition.</returns>
internal int AlarmNotifierCount(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Raw)
{
lock (Lock)
{
if (!_alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var alarm)) return 0;
var list = new List<NodeState.Notifier>();
alarm.GetNotifiers(SystemContext, list);
return list.Count;
}
}
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
/// materialised folder <paramref name="folderNodeId"/> — for an equipment folder wired as an alarm notifier
/// this counts the inverse links back to its condition(s). Zero when the folder is absent.</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="realm">The realm the folder lives in (Uns for equipment folders).</param>
/// <returns>The number of notifier entries on the folder.</returns>
internal int FolderNotifierCount(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
{
if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return 0;
var list = new List<NodeState.Notifier>();
folder.GetNotifiers(SystemContext, list);
return list.Count;
}
}
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): true when <paramref name="folderNodeId"/> is
/// registered as a root (Server-object) event notifier — i.e. it was promoted via
/// <see cref="EnsureFolderIsEventNotifier"/> and not yet torn down.</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="realm">The realm the folder lives in.</param>
/// <returns>True when the folder is a registered root notifier.</returns>
internal bool IsRootNotifier(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
return _folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)
&& _notifierFolders.ContainsKey(folder.NodeId);
}
/// <summary>
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
/// variable node on first call; subsequent calls update Value + StatusCode +
@@ -825,6 +884,91 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>One materialised native-alarm condition together with the equipment folders wired as extra
/// event-notifier roots for it. The <see cref="Folders"/> list is mutated in place under <c>Lock</c> as
/// notifiers are wired / torn down; <see cref="Alarm"/> is the concrete <see cref="AlarmConditionState"/>
/// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when
/// the instance changes).</summary>
private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List<FolderState> Folders);
/// <inheritdoc cref="IOpcUaAddressSpaceSink.WireAlarmNotifiers"/>
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentNullException.ThrowIfNull(notifierFolderNodeIds);
if (notifierFolderNodeIds.Count == 0) return;
EnsureAddressSpaceCreated();
var alarmKey = MapKey(alarmRealm, alarmNodeId);
lock (Lock)
{
// The condition must already be materialised (MaterialiseAlarmCondition ran first in the same
// apply pass). A miss ⇒ a mid-rebuild race cleared it: no-op (logged), never throw — a deploy must
// not fault on the notifier wiring.
if (!_alarmConditions.TryGetValue(alarmKey, out var alarm))
{
// This CustomNodeManager2 carries no ILogger; log through the SDK's static trace (see the
// ReportEvent catch below for the same pattern). Utils.LogInfo is [Obsolete] in 1.5.378 in
// favour of an ITelemetryContext this manager doesn't wire — suppress the deprecation.
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: condition {0} not materialised — skipping notifier wiring", alarmKey);
#pragma warning restore CS0618
return;
}
// Get-or-create the wiring entry; a rebuild recreated the AlarmConditionState instance, so reset
// the entry (the old folder list referenced the discarded instance) when the instance differs.
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring) || !ReferenceEquals(wiring.Alarm, alarm))
{
wiring = new AlarmNotifierWiring(alarm, new List<FolderState>());
_alarmNotifierWiring[alarmKey] = wiring;
}
foreach (var folderNodeId in notifierFolderNodeIds)
{
if (!_folders.TryGetValue(MapKey(notifierFolderRealm, folderNodeId), out var folder))
{
// Referencing-equipment folder not (yet) materialised — skip this one (logged, never thrown).
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: notifier folder {0} for condition {1} not present — skipped",
folderNodeId, alarmKey);
#pragma warning restore CS0618
continue;
}
// Normative SDK pattern (design §"OPC UA address space + runtime binding"): a single
// ReportEvent on the condition bubbles through its HasComponent parent chain PLUS every inverse
// entry in its notifier list, so one event fans to every wired root WITHOUT re-reporting per
// root (distinct EventIds would break Server-object dedup + Part 9 ack correlation).
// alarm.AddNotifier(isInverse:true, folder) — the upward bubble to the equipment folder
// folder.AddNotifier(isInverse:false, alarm) — the downward AreEventsMonitored link
// EnsureFolderIsEventNotifier(folder) — SubscribeToEvents + AddRootNotifier (idempotent)
// AddNotifier dedups by ReferenceEquals(Node), so re-wiring the same pair (idempotent re-apply)
// updates rather than duplicates.
alarm.AddNotifier(SystemContext, null, isInverse: true, folder);
folder.AddNotifier(SystemContext, null, isInverse: false, alarm);
EnsureFolderIsEventNotifier(folder);
if (!wiring.Folders.Any(f => ReferenceEquals(f, folder))) wiring.Folders.Add(folder);
}
}
}
/// <summary>Tear down (bidirectionally) every notifier wired for the condition at <paramref name="alarmKey"/>
/// and drop its tracking entry. MUST be called under <c>Lock</c>. Used on condition-removal + full rebuild
/// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers.</summary>
private void UnwireAlarmNotifiers(string alarmKey)
{
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring)) return;
foreach (var folder in wiring.Folders)
{
// bidirectional:true also removes the inverse entry the folder holds back to the condition.
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
}
_alarmNotifierWiring.Remove(alarmKey);
}
/// <summary>H6a — true if the condition materialised at <paramref name="alarmNodeId"/> is a NATIVE
/// (driver-fed) alarm rather than a scripted one. A later task uses this to route a native condition's
/// inbound Acknowledge to the driver instead of the scripted engine.</summary>
@@ -1973,6 +2117,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
alarm.Parent?.RemoveChild(alarm);
PredefinedNodes?.Remove(alarm.NodeId);
}
// v3 Batch 4 WP4: tear down every native-alarm→equipment-folder notifier pair bidirectionally
// BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the
// rebuild. The condition + folder NodeState objects still exist here (the loops above/below only
// detach them from their parents + PredefinedNodes), so RemoveNotifier is valid; then clear the
// tracking map so the re-materialise + re-wire on the next apply starts from a clean slate.
foreach (var alarmKey in _alarmNotifierWiring.Keys.ToList())
UnwireAlarmNotifiers(alarmKey);
_alarmConditions.Clear();
// H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
// re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
@@ -2043,6 +2195,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole
// equipment goes).
if (!_alarmConditions.TryRemove(key, out var condition)) return false;
// v3 Batch 4 WP4: unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE
// the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without
// this they would keep a dangling inverse-notifier entry to the removed condition.
UnwireAlarmNotifiers(key);
condition.Parent?.RemoveChild(condition);
PredefinedNodes?.Remove(condition.NodeId);
_nativeAlarmNodeIds.Remove(key);
@@ -2095,6 +2251,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
_nativeAlarmNodeIds.Remove(id);
}
// v3 Batch 4 WP4: a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this
// UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra
// event-notifier root. Unwire those condition↔folder pairs bidirectionally BEFORE the folders drop,
// else the surviving condition keeps a dangling inverse-notifier entry to a removed folder. (The
// condition's own scoped removal — if its raw tag is also removed this apply — runs later via
// RemoveAlarmConditionNode; this only reaches surviving conditions.)
foreach (var wiring in _alarmNotifierWiring.Values)
{
foreach (var folder in wiring.Folders.Where(f => InScope(MapKey(f.NodeId))).ToList())
{
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
wiring.Folders.Remove(folder);
}
}
// Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for
// every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an
// alarm), else the removal leaks an orphaned root-notifier reference on the Server object.
@@ -32,6 +32,10 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc />
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
=> _nodeManager.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);