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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user