diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs
index 45c35d5b..3777efe4 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
/// OPC UA Part 9 condition subtype name — one of LimitAlarm / DiscreteAlarm / OffNormalAlarm / AlarmCondition (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.
/// Operator-supplied comment on ack / confirm / comment transitions; null for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.
/// When false, the durable historian sink suppresses this transition (the live alerts fan-out is unaffected); null or true historize. null is the cross-version/rolling-restart case: an old-format message missing the field deserializes to null (CLR default for bool?) and is historized (safe default-on), matching the AlarmTypeName null-coalesce in HistorianAdapterActor.Translate. The producer (ScriptedAlarmHostActor) always sets a concrete true/false.
+/// v3 Batch 4 (multi-notifier native alarms) — the (possibly empty) list of UNS equipment-folder paths (Area/Line/Equipment) that reference the alarm's backing raw tag. A native alarm is a SINGLE Part 9 condition materialised once at the raw tag (its is the RawPath); the same condition fans events to every referencing equipment's UNS folder via SDK notifiers, and this list is carried so /alerts 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.
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? ReferencingEquipmentPaths = null);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
index 068c42a9..6b8f2952 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
@@ -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);
+ ///
+ // 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 notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
+ => _inner.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
+
///
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
index 57225516..54913bd8 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
@@ -51,6 +51,28 @@ public interface IOpcUaAddressSpaceSink
/// live in.
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false);
+ ///
+ /// v3 Batch 4 (multi-notifier native alarms) — wire an already-materialised native alarm condition
+ /// (, materialised at the raw tag via
+ /// with ConditionId = the RawPath) as an event notifier of EACH referencing equipment's UNS folder, so
+ /// the condition's singleReportEvent 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
+ /// (alarm.AddNotifier(isInverse:true, folder) + folder.AddNotifier(isInverse:false, alarm))
+ /// and promotes the folder to an event notifier. Idempotent (a re-wire of the same pair updates,
+ /// never duplicates); a missing endpoint is a no-op (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).
+ ///
+ /// The native alarm condition's node id (== the backing tag's RawPath).
+ /// The namespace realm the condition lives in (Raw for native alarms).
+ /// The equipment folder node ids (their s= ids) to wire as
+ /// extra event-notifier roots for this condition. An unknown / not-yet-materialised folder id is skipped.
+ /// The namespace realm the notifier folders live in (Uns for equipment folders).
+ void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
+ IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm);
+
///
/// Ensure a folder node exists under the given parent. Used by AddressSpaceApplier to
/// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
@@ -147,6 +169,9 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
///
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
+ ///
+ public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
+
///
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor
index a9c77a6c..dca6f8df 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor
@@ -64,7 +64,22 @@ else
@e.TimestampUtc.ToString("HH:mm:ss.fff")
@e.AlarmId
@e.AlarmName
-
@e.EquipmentPath
+
+ @e.EquipmentPath
+ @* 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)
+ {
+
+ @foreach (var p in refs)
+ {
+ @p
+ }
+
+ }
+
@e.TransitionKind
@e.Severity
@e.User
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
index 277f824b..6c6caea9 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
@@ -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? 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; }
}
+
+ /// Wire a native alarm condition's extra equipment-folder notifiers (WP4 multi-notifier),
+ /// swallowing (and Warning-logging) any sink fault. Returns true on success, false when the
+ /// sink threw — callers tally the false into their pass's failed-node count (archreview 01/S-1).
+ /// true when the notifiers were wired; false when the sink threw.
+ private bool SafeWireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList 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; }
+ }
+
+ ///
+ /// v3 Batch 4 WP4 — build the reverse map equipment UNS folder PATH (Area/Line/Equipment) →
+ /// EquipmentId. The composer emits a native alarm's
+ /// as name paths (V3NodeIds.Uns(areaName, lineName, equipName)), but the equipment folders were
+ /// materialised under their logical EquipmentId by , 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 and is skipped (the same
+ /// drop the composer applies), so an entry the composer produced always resolves here.
+ ///
+ /// The composition carrying the UNS topology + equipment nodes.
+ /// A map from each resolvable equipment folder path to its EquipmentId.
+ private static IReadOnlyDictionary 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(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 of one apply pass. Useful for tests + audit-log entries on the deploy path.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index e0f3d357..fedca5c5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -71,6 +71,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// Keyed by NodeId → the actual so can
/// pass the folder to RemoveRootNotifier on teardown.
private readonly Dictionary _notifierFolders = new();
+ /// 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 ).
+ /// A native alarm is a single condition at the raw tag (ConditionId = RawPath); its single
+ /// ReportEvent 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 ' keys). On rebuild / condition-removal / equipment-subtree-removal
+ /// each wired pair is torn down bidirectionally (RemoveNotifier(bidirectional:true)) so an
+ /// inverse-notifier entry never leaks across redeploys. Guarded by the same Lock as
+ /// / .
+ private readonly Dictionary _alarmNotifierWiring = new(StringComparer.Ordinal);
/// Phase C: event-notifier folder NodeId-identifier → the event-history source
/// name passed to . 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;
+ /// Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
+ /// materialised alarm condition — for a native alarm this is the number of
+ /// referencing equipment folders wired via . Zero when the condition is
+ /// absent. Used by the multi-notifier + teardown-symmetry tests to prove no notifier entry leaks/duplicates
+ /// across redeploys.
+ /// The alarm condition node identifier.
+ /// The realm the condition lives in (Raw for native alarms).
+ /// The number of notifier entries on the condition.
+ 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();
+ alarm.GetNotifiers(SystemContext, list);
+ return list.Count;
+ }
+ }
+
+ /// Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
+ /// materialised folder — 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.
+ /// The folder node identifier.
+ /// The realm the folder lives in (Uns for equipment folders).
+ /// The number of notifier entries on the folder.
+ 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();
+ folder.GetNotifiers(SystemContext, list);
+ return list.Count;
+ }
+ }
+
+ /// Test/diagnostic accessor (v3 Batch 4 WP4): true when is
+ /// registered as a root (Server-object) event notifier — i.e. it was promoted via
+ /// and not yet torn down.
+ /// The folder node identifier.
+ /// The realm the folder lives in.
+ /// True when the folder is a registered root notifier.
+ internal bool IsRootNotifier(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
+ {
+ lock (Lock)
+ return _folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)
+ && _notifierFolders.ContainsKey(folder.NodeId);
+ }
+
///
/// Apply a value write from . Creates the
/// variable node on first call; subsequent calls update Value + StatusCode +
@@ -825,6 +884,91 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
+ /// One materialised native-alarm condition together with the equipment folders wired as extra
+ /// event-notifier roots for it. The list is mutated in place under Lock as
+ /// notifiers are wired / torn down; is the concrete
+ /// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when
+ /// the instance changes).
+ private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List Folders);
+
+ ///
+ public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
+ IReadOnlyList 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());
+ _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);
+ }
+ }
+ }
+
+ /// Tear down (bidirectionally) every notifier wired for the condition at
+ /// and drop its tracking entry. MUST be called under Lock. Used on condition-removal + full rebuild
+ /// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers.
+ 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);
+ }
+
/// H6a — true if the condition materialised at 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.
@@ -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.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
index 084ba026..814e8304 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
@@ -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);
+ ///
+ public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
+ => _nodeManager.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
+
///
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs
index 3d152e1c..0a63f400 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs
@@ -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 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 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 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)
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs
index b3ceb8f6..2fcf5470 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs
@@ -189,6 +189,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
}
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
+ public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs
index 5c6edd6e..c84241b6 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs
@@ -338,6 +338,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
public void RebuildAddressSpace() { }
/// Announces a NodeAdded model-change (stub implementation for testing).
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
+ public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs
index 392ea476..c4fc7bf7 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs
@@ -107,6 +107,49 @@ public sealed class AddressSpaceApplierRawUnsTests
c.Parent.ShouldBe("Plant/A/dev");
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
c.IsNative.ShouldBeTrue();
+ // No referencing equipment ⇒ no multi-notifier wiring.
+ sink.NotifierWirings.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void MaterialiseRawSubtree_alarm_tag_wires_a_notifier_per_referencing_equipment_folder()
+ {
+ var sink = new RealmRecordingSink();
+ // UNS topology so the alarm's referencing-equipment PATHS (Area/Line/Equipment) resolve to the
+ // EquipmentId the equipment folders were materialised under. Two equipment reference the one alarm tag.
+ var composition = new AddressSpaceComposition(
+ new[] { new UnsAreaProjection("a1", "filling") },
+ new[] { new UnsLineProjection("l1", "a1", "line1"), new UnsLineProjection("l2", "a1", "line2") },
+ new[]
+ {
+ new EquipmentNode("EQ-1", "station1", "l1"),
+ new EquipmentNode("EQ-2", "station2", "l2"),
+ },
+ Array.Empty(),
+ Array.Empty())
+ {
+ RawTags = new[]
+ {
+ new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
+ Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
+ // The composer emits Area/Line/Equipment NAME paths here.
+ ReferencingEquipmentPaths: new[] { "filling/line1/station1", "filling/line2/station2" }),
+ },
+ };
+
+ NewApplier(sink).MaterialiseRawSubtree(composition);
+
+ // The single condition is materialised ONCE at the RawPath (Raw realm)...
+ var c = sink.Conditions.ShouldHaveSingleItem();
+ c.NodeId.ShouldBe("Plant/A/dev/OverTemp");
+ c.Realm.ShouldBe(AddressSpaceRealm.Raw);
+ // ...and wired as a notifier of BOTH referencing equipment folders — resolved from the name paths to the
+ // logical EquipmentId folder NodeIds, in the Uns realm. ONE wiring call (single condition), not per-root.
+ var w = sink.NotifierWirings.ShouldHaveSingleItem();
+ w.AlarmNodeId.ShouldBe("Plant/A/dev/OverTemp");
+ w.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw);
+ w.FolderRealm.ShouldBe(AddressSpaceRealm.Uns);
+ w.FolderNodeIds.ShouldBe(new[] { "EQ-1", "EQ-2" }, ignoreOrder: true);
}
[Fact]
@@ -156,6 +199,7 @@ public sealed class AddressSpaceApplierRawUnsTests
public List<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> Variables { get; } = new();
public List<(string NodeId, string? Parent, bool IsNative, AddressSpaceRealm Realm)> Conditions { get; } = new();
public List<(string Source, AddressSpaceRealm SourceRealm, string Target, AddressSpaceRealm TargetRealm, string ReferenceType)> References { get; } = new();
+ public List<(string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList FolderNodeIds, AddressSpaceRealm FolderRealm)> NotifierWirings { get; } = new();
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> Folders.Add((folderNodeId, parentNodeId, realm));
@@ -166,6 +210,9 @@ public sealed class AddressSpaceApplierRawUnsTests
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm));
+ public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
+ => NotifierWirings.Add((alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm));
+
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
index f42e497b..150f55f2 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs
@@ -2307,6 +2307,7 @@ public sealed class AddressSpaceApplierTests
/// Records a NodeAdded model-change announcement.
/// The node under which discovered nodes were added.
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId);
+ public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
@@ -2332,6 +2333,7 @@ public sealed class AddressSpaceApplierTests
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// No-op NodeAdded model-change announcement.
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
+ public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
@@ -2381,6 +2383,7 @@ public sealed class AddressSpaceApplierTests
public void RebuildAddressSpace() { }
/// No-op NodeAdded model-change announcement.
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
+ public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
index 6a4c66c3..680c0f73 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
@@ -249,6 +249,7 @@ public sealed class DeferredAddressSpaceSinkTests
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
///
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"NA:{affectedNodeId}");
+ public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
@@ -299,6 +300,7 @@ public sealed class DeferredAddressSpaceSinkTests
public void RebuildAddressSpace() { }
///
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
+ public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMultiNotifierAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMultiNotifierAlarmTests.cs
new file mode 100644
index 00000000..6840da86
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMultiNotifierAlarmTests.cs
@@ -0,0 +1,216 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+///
+/// v3 Batch 4 (B4-WP4) — the native-alarm multi-notifier wiring +
+/// teardown symmetry on . A native alarm is a SINGLE Part 9
+/// condition materialised once at its raw tag (ConditionId = RawPath, Raw realm);
+/// wires that one condition as an event notifier of
+/// EACH referencing equipment's UNS folder so one ReportEvent fans one event to every referencing
+/// equipment (never re-reported per root). The obligation these tests lock in: the wiring is idempotent,
+/// a missing folder is a safe skip, and every teardown path (condition-removal / equipment-subtree-removal
+/// / full rebuild + re-wire) removes the notifier pair BIDIRECTIONALLY so no inverse-notifier entry leaks
+/// across redeploys (exactly N notifiers after a re-trip, not 2N).
+///
+public sealed class NodeManagerMultiNotifierAlarmTests : IDisposable
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private const string RawAlarm = "Plant/Modbus/dev1/temp_hi";
+ private const string RawDeviceFolder = "Plant/Modbus/dev1";
+ private const string Equip1 = "EQ-filling-line1-station1";
+ private const string Equip2 = "EQ-filling-line2-station2";
+
+ private readonly string _pkiRoot = Path.Combine(
+ Path.GetTempPath(),
+ $"otopcua-multi-notifier-{Guid.NewGuid():N}");
+
+ /// Materialise the raw device folder + the single native condition + two equipment folders, then
+ /// wire the condition as a notifier of both. Returns the node manager.
+ private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm)> BootWithTwoEquipmentAsync()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ // Raw device folder (the condition's HasComponent parent) + the single native condition at the RawPath.
+ nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
+ nm.MaterialiseAlarmCondition(RawAlarm, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Raw);
+ // Two referencing equipment folders (UNS realm).
+ nm.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
+ nm.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns);
+ return (host, nm);
+ }
+
+ /// The single condition is wired as a notifier of EACH referencing equipment folder: the condition
+ /// carries one inverse-notifier entry per folder, each folder carries the inverse back to the condition, and
+ /// each folder becomes a root (Server-object) event notifier.
+ [Trait("Category", "Unit")]
+ [Fact]
+ public async Task WireAlarmNotifiers_wires_the_single_condition_to_each_equipment_folder()
+ {
+ var (host, nm) = await BootWithTwoEquipmentAsync();
+
+ nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
+
+ // One inverse-notifier entry per equipment folder on the single condition.
+ nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
+ // Each equipment folder holds the inverse entry back to the condition + is a root notifier.
+ nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
+ nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
+ nm.IsRootNotifier(Equip1, AddressSpaceRealm.Uns).ShouldBeTrue();
+ nm.IsRootNotifier(Equip2, AddressSpaceRealm.Uns).ShouldBeTrue();
+
+ await host.DisposeAsync();
+ }
+
+ /// Re-wiring the SAME pairs (an idempotent re-apply of the raw subtree pass) does not duplicate the
+ /// notifier entries — the SDK dedups by node reference and the tracking dedups its list.
+ [Trait("Category", "Unit")]
+ [Fact]
+ public async Task WireAlarmNotifiers_is_idempotent_no_duplicate_on_re_wire()
+ {
+ var (host, nm) = await BootWithTwoEquipmentAsync();
+
+ nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
+ nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
+ nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
+
+ nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
+ nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
+ nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
+
+ await host.DisposeAsync();
+ }
+
+ /// A referencing equipment folder that is not (yet) materialised is skipped (no throw); the present
+ /// folders are still wired. A missing condition is likewise a no-op.
+ [Trait("Category", "Unit")]
+ [Fact]
+ public async Task WireAlarmNotifiers_missing_folder_or_condition_is_a_safe_skip()
+ {
+ var (host, nm) = await BootWithTwoEquipmentAsync();
+
+ // "EQ-missing" was never materialised — it is skipped; Equip1 is still wired.
+ nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, "EQ-missing" }, AddressSpaceRealm.Uns);
+ nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
+ nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
+
+ // An unmaterialised condition id is a no-op (never throws).
+ Should.NotThrow(() =>
+ nm.WireAlarmNotifiers("Plant/Modbus/dev1/does_not_exist", AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns));
+
+ await host.DisposeAsync();
+ }
+
+ /// Removing the condition in place (surgical raw-alarm-tag removal) tears the notifier pairs down
+ /// BIDIRECTIONALLY: the surviving equipment folders lose their inverse-notifier entry back to the removed
+ /// condition (no dangling reference).
+ [Trait("Category", "Unit")]
+ [Fact]
+ public async Task RemoveAlarmConditionNode_unwires_notifiers_from_surviving_folders()
+ {
+ var (host, nm) = await BootWithTwoEquipmentAsync();
+ nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
+ nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
+
+ nm.RemoveAlarmConditionNode(RawAlarm, AddressSpaceRealm.Raw).ShouldBeTrue();
+
+ // Condition gone; the surviving equipment folders no longer reference it (bidirectional teardown).
+ nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldBeNull();
+ nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(0);
+ nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(0);
+
+ await host.DisposeAsync();
+ }
+
+ /// Removing one referencing equipment's subtree unwires ONLY that folder from the surviving raw
+ /// condition (which lives in the Raw realm and is untouched by a UNS subtree removal): the condition drops
+ /// exactly one notifier and keeps the other.
+ [Trait("Category", "Unit")]
+ [Fact]
+ public async Task RemoveEquipmentSubtree_unwires_only_that_folder_from_surviving_condition()
+ {
+ var (host, nm) = await BootWithTwoEquipmentAsync();
+ nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
+ nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
+
+ nm.RemoveEquipmentSubtree(Equip1, AddressSpaceRealm.Uns).ShouldBeTrue();
+
+ // The raw condition SURVIVES (Raw realm) and now notifies only the remaining equipment folder.
+ nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldNotBeNull();
+ nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
+ nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
+ nm.IsRootNotifier(Equip2, AddressSpaceRealm.Uns).ShouldBeTrue();
+
+ await host.DisposeAsync();
+ }
+
+ /// Teardown-symmetry / "exactly one copy after a re-trip": a full rebuild + re-materialise +
+ /// re-wire leaves EXACTLY the same notifier count (2), not a doubled/leaked set — the rebuild unwired the
+ /// prior notifier pairs bidirectionally before dropping the nodes.
+ [Trait("Category", "Unit")]
+ [Fact]
+ public async Task Rebuild_then_rewire_has_no_leaked_notifier_duplicates()
+ {
+ var (host, nm) = await BootWithTwoEquipmentAsync();
+ nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
+ nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
+
+ // Redeploy (the full-rebuild path).
+ nm.RebuildAddressSpace();
+ nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldBeNull();
+
+ // Re-materialise the whole thing + re-wire (what the applier does every apply).
+ nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
+ nm.MaterialiseAlarmCondition(RawAlarm, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Raw);
+ nm.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
+ nm.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns);
+ nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
+
+ // Exactly 2 again — no leaked inverse-notifier entries carried across the rebuild.
+ nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
+ nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
+ nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
+
+ await host.DisposeAsync();
+ }
+
+ private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
+ {
+ var host = new OpcUaApplicationHost(
+ new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "OtOpcUa.MultiNotifierTest",
+ ApplicationUri = $"urn:OtOpcUa.MultiNotifierTest:{Guid.NewGuid():N}",
+ OpcUaPort = AllocateFreePort(),
+ PublicHostname = "localhost",
+ PkiStoreRoot = _pkiRoot,
+ },
+ Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
+
+ var server = new OtOpcUaSdkServer();
+ await host.StartAsync(server, Ct);
+ return (host, server);
+ }
+
+ private static int AllocateFreePort()
+ {
+ using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ /// Cleans up the PKI root directory.
+ public void Dispose()
+ {
+ if (Directory.Exists(_pkiRoot))
+ {
+ try { Directory.Delete(_pkiRoot, recursive: true); }
+ catch { /* best-effort cleanup */ }
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs
index bdbeab4c..148154aa 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs
@@ -346,6 +346,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
/// Records a NodeAdded model-change announcement.
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId);
+ public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs
index 55464a3e..6b6bf0e1 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs
@@ -19,21 +19,22 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
///
-/// Verifies the inbound native-condition acknowledge routing wired into
-/// (H6d): an OPC UA client Acknowledges a NATIVE condition, the
-/// node manager invokes NativeAlarmAckRouter, and the host (NEXT task) Tells a
-/// in. The host resolves the condition NodeId →
-/// owning (DriverInstanceId, FullName) via the _driverRefByAlarmNodeId inverse map
-/// (built alongside the alarm forward map in PushDesiredSubscriptions), applies the SAME
+/// v3 Batch 4 (B4-WP4) — the inbound native-condition acknowledge routing wired into
+/// re-expressed for the v3 raw-condition model. An OPC UA client Acknowledges
+/// a NATIVE condition (materialised at the raw tag, ConditionId = its RawPath); the node manager invokes
+/// NativeAlarmAckRouter and the host receives a .
+/// The host resolves the condition NodeId (== the RawPath) → owning (DriverInstanceId, RawPath) via
+/// the _driverRefByAlarmNodeId inverse map (built alongside the alarm forward map in
+/// PushDesiredSubscriptions from the alarm-bearing composition.RawTags), applies the SAME
/// primary gate the inbound write path uses, and routes to the owning driver child's
/// carrying the principal.
///
///
-/// Mirrors DriverHostActorWriteRoutingTests: a real apply through the existing harness
-/// spawns a real (non-stubbed) child backed by a recording
-/// driver, so the inverse map is populated authentically and the
-/// forwarded acknowledge request can be observed. The seeded tag carries an alarm object so
-/// it materialises as a Part 9 condition (folder-scoped condition NodeId), not a value variable.
+/// Mirrors DriverHostActorLiveValueTests: a real apply through the harness spawns a real
+/// (non-stubbed) child backed by a recording
+/// driver (DriverType "GalaxyMxGateway", Enabled), so the inverse map is populated authentically and the
+/// forwarded acknowledge request can be observed. The seeded raw tag carries an alarm object so it
+/// materialises as a Part 9 condition at its RawPath, not a value variable.
///
///
public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTestBase
@@ -42,31 +43,31 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
- /// On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped condition NodeId
- /// forwards exactly one to the owning driver's
- /// , with ConditionId == FullName, the operator
- /// principal, and the comment.
- [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
+ // The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "gw" / Device "dev1" / Tag.
+ private const string AlarmRawPath = "Plant/gw/dev1/temp_hi";
+
+ /// On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped raw condition NodeId
+ /// (== the RawPath) forwards exactly one to the owning driver's
+ /// , correlated on the RawPath, with the operator principal + the
+ /// comment.
+ [Fact]
public void RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
- // One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi"
- // → condition NodeId "eq-1/temp_hi".
- var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
- Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
+ var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics).
- actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice"));
+ actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
- // The driver received exactly one acknowledge, correlated on its wire-ref FullName, with principal.
+ // The driver received exactly one acknowledge, correlated on its wire-ref RawPath, with principal.
AwaitAssert(() =>
{
recorder.Acks.Count.ShouldBe(1);
- recorder.Acks[0].ConditionId.ShouldBe("Temp.HiHi");
- recorder.Acks[0].SourceNodeId.ShouldBe("Temp.HiHi");
+ recorder.Acks[0].ConditionId.ShouldBe(AlarmRawPath);
+ recorder.Acks[0].SourceNodeId.ShouldBe(AlarmRawPath);
recorder.Acks[0].Comment.ShouldBe("cmt");
recorder.Acks[0].OperatorUser.ShouldBe("alice");
}, duration: Timeout);
@@ -79,27 +80,24 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
- var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
- Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
+ var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var actor = SpawnHostAndApply(db, deploymentId, recorder);
- actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/does-not-exist", "cmt", "alice"));
+ actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/does-not-exist", "cmt", "alice"));
// Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack.
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
}
/// On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the
- /// driver's is NOT called — a secondary keeps its address
- /// space warm but must not push commands to the shared upstream alarm system.
+ /// driver's is NOT called.
[Fact]
public void RouteNativeAlarmAck_on_non_primary_is_dropped()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
- var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
- Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
+ var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var actor = SpawnHostAndApply(db, deploymentId, recorder);
@@ -112,7 +110,7 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
},
CorrelationId.NewId()));
- actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice"));
+ actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
// No ack reached the driver — the gate short-circuited before the inverse-map lookup.
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
@@ -137,51 +135,48 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
}
///
- /// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's
- /// TagConfig carries both a FullName and an alarm object so
- /// DeploymentArtifact.ExtractTagAlarm projects a non-null EquipmentTagAlarmInfo —
- /// making the tag a condition (folder-scoped condition NodeId) rather than a value variable. The
- /// DriverInstances row carries a non-Windows-only DriverType ("GalaxyMxGateway") + an
- /// Enabled flag so a REAL (non-stubbed) child is spawned.
+ /// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
+ /// DriverInstance(RawFolderId, Name "gw", DriverType "GalaxyMxGateway", Enabled) → Device "dev1" → Tag)
+ /// with the tag's TagConfig carrying an alarm object so the composer projects a non-null
+ /// EquipmentTagAlarmInfo — making the raw tag a Part 9 condition at its RawPath. The non-Windows
+ /// DriverType + Enabled flag spawn a REAL child.
///
- private static DeploymentId SeedDeploymentWithAlarmTag(
- IDbContextFactory db, RevisionHash rev,
- string Equip, string Driver, string FullName, string? Folder, string Name)
+ private static DeploymentId SeedV3AlarmDeployment(
+ IDbContextFactory db, RevisionHash rev, string Driver, string Tag)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
- Namespaces = new[]
- {
- new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
- },
+ RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new
{
DriverInstanceRowId = Guid.NewGuid(),
DriverInstanceId = Driver,
- Name = Driver,
+ RawFolderId = "rf-plant",
+ Name = "gw",
DriverType = "GalaxyMxGateway", // not Windows-only ⇒ a real child is spawned (not stubbed)
Enabled = true,
DriverConfig = "{}",
- NamespaceId = "ns-eq",
+ ClusterId = "c1",
},
},
+ Devices = new[]
+ {
+ new { DeviceId = $"{Driver}:dev1", DriverInstanceId = Driver, Name = "dev1", DeviceConfig = "{}" },
+ },
+ TagGroups = Array.Empty
+ [Fact]
public void Native_alarm_operator_comment_flows_to_transition_event()
{
var db = NewInMemoryDbFactory();
- var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
- Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
+ var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
- // Send an alarm whose OperatorComment is set — simulates an upstream acknowledge-with-comment.
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp",
- ConditionId: "Temp.HiHi",
+ ConditionId: AlarmRawPath,
AlarmType: "OffNormalAlarm",
Message: "investigating",
Severity: AlarmSeverity.High,
@@ -285,18 +264,14 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
Kind: AlarmTransitionKind.Acknowledge,
OperatorComment: "investigating")));
- // OPC UA condition update is ungated — drain it.
publish.ExpectMsg(TimeSpan.FromSeconds(5));
- // The published AlarmTransitionEvent must carry the comment + the "device" user marker.
var evt = alerts.ExpectMsg(TimeSpan.FromSeconds(5));
evt.Comment.ShouldBe("investigating");
evt.User.ShouldBe("device");
}
- /// Subscribe to the alerts DPS topic and wait for the ack.
- /// The Subscribe is sent FROM the probe so the SubscribeAck returns to it. Mirrors the
- /// ScriptedAlarmHostActor test harness.
+ /// Subscribe to the alerts DPS topic and wait for the ack.
private void SubscribeToAlerts(TestProbe probe)
{
DistributedPubSub.Get(Sys).Mediator.Tell(
@@ -304,9 +279,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
probe.ExpectMsg(TimeSpan.FromSeconds(5));
}
- /// Tell the host a snapshot marking the host's own
- /// node (, which equals the host's _localNode) with
- /// so the alerts-publish gate observes the local role.
+ /// Tell the host a snapshot marking the host's own node
+ /// () with so the alerts-publish gate observes it.
private static void TellRedundancyRole(IActorRef host, RedundancyRole role)
{
host.Tell(new RedundancyStateChanged(
@@ -322,10 +296,10 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
CorrelationId.NewId()));
}
- /// Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied
- /// ACK so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the
- /// test publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.
- private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply(
+ /// Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied ACK
+ /// so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the test
+ /// publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.
+ private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(
IDbContextFactory db, DeploymentId deploymentId)
{
var coordinator = CreateTestProbe();
@@ -352,47 +326,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
}
///
- /// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's
- /// TagConfig carries both a FullName and an alarm object
- /// (alarmType + severity) so DeploymentArtifact.ExtractTagAlarm projects a
- /// non-null EquipmentTagAlarmInfo (here OffNormalAlarm / 700) — making the tag a
- /// condition rather than a value variable.
+ /// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
+ /// DriverInstance(RawFolderId, Name "Modbus") → Device "dev1" → Tag) with the tag's TagConfig
+ /// carrying an alarm object so the composer projects a non-null EquipmentTagAlarmInfo
+ /// (OffNormalAlarm / 700) — making the raw tag a Part 9 condition at its RawPath rather than a
+ /// value variable. Enums serialize numerically. No UNS reference is needed for these routing/emit tests
+ /// (the SDK-notifier fan-out to referencing equipment is covered by the node-manager tests).
///
- private static DeploymentId SeedDeploymentWithAlarmTag(
+ private static DeploymentId SeedV3AlarmDeployment(
IDbContextFactory db, RevisionHash rev,
- string Equip, string Driver, string FullName, string? Folder, string Name,
- bool? historizeToAveva = null)
+ string Driver, string Tag, bool? historizeToAveva = null)
{
- // historizeToAveva absent (null) ⇒ omit the key entirely so the absent ⇒ historize default path is
- // exercised; a concrete true/false writes the bool into the alarm object so the native path threads it.
+ // historizeToAveva absent (null) ⇒ omit the key so the absent ⇒ historize default path is exercised;
+ // a concrete true/false writes the bool into the alarm object so the native path threads it.
object alarm = historizeToAveva is { } h
? new { alarmType = "OffNormalAlarm", severity = 700, historizeToAveva = h }
: new { alarmType = "OffNormalAlarm", severity = 700 };
+
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
- Namespaces = new[]
- {
- new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
- },
+ RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
- new { DriverInstanceId = Driver, NamespaceId = "ns-eq" },
+ new { DriverInstanceId = Driver, RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
},
+ Devices = new[]
+ {
+ new { DeviceId = $"{Driver}:dev1", DriverInstanceId = Driver, Name = "dev1", DeviceConfig = "{}" },
+ },
+ TagGroups = Array.Empty