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