From 8ebc712effde999a382601653abd906cdf62bd95 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 12:07:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(v3-batch4-wp4):=20multi-notifier=20native?= =?UTF-8?q?=20alarms=20(single=20ReportEvent=20=E2=86=92=20raw=20+=20equip?= =?UTF-8?q?ment=20notifiers)=20+=20teardown=20symmetry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Messages/Alerts/AlarmTransitionEvent.cs | 4 +- .../OpcUa/DeferredAddressSpaceSink.cs | 7 + .../OpcUa/IOpcUaAddressSpaceSink.cs | 25 ++ .../Components/Pages/Alerts.razor | 17 +- .../AddressSpaceApplier.cs | 75 +++++- .../OtOpcUaNodeManager.cs | 171 ++++++++++++++ .../SdkAddressSpaceSink.cs | 4 + .../OpcUa/DeferredAddressSpaceSinkTests.cs | 23 ++ .../AddressSpaceApplierFailureSurfaceTests.cs | 1 + .../AddressSpaceApplierHierarchyTests.cs | 1 + .../AddressSpaceApplierRawUnsTests.cs | 47 ++++ .../AddressSpaceApplierTests.cs | 3 + .../DeferredAddressSpaceSinkTests.cs | 2 + .../NodeManagerMultiNotifierAlarmTests.cs | 216 +++++++++++++++++ .../DiscoveryInjectionEndToEndTests.cs | 1 + ...iverHostActorNativeAlarmAckRoutingTests.cs | 107 ++++----- .../DriverHostActorNativeAlarmTests.cs | 217 ++++++++---------- .../OtOpcUaTelemetryHookTests.cs | 1 + .../OpcUaPublishActorApplyFailureTests.cs | 2 + .../OpcUa/OpcUaPublishActorRebuildTests.cs | 1 + .../OpcUa/OpcUaPublishActorTests.cs | 1 + 21 files changed, 744 insertions(+), 182 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMultiNotifierAlarmTests.cs 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 single ReportEvent 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(), Tags = new[] { new { TagId = "tag-0", - EquipmentId = Equip, - DriverInstanceId = Driver, - Name, - FolderPath = Folder, + DeviceId = $"{Driver}:dev1", + TagGroupId = (string?)null, + Name = Tag, DataType = "Boolean", - TagConfig = JsonSerializer.Serialize(new - { - FullName, - alarm = new { alarmType = "OffNormalAlarm", severity = 700 }, - }), + AccessLevel = 0, // TagAccessLevel.Read + TagConfig = JsonSerializer.Serialize(new { alarm = new { alarmType = "OffNormalAlarm", severity = 700 } }), }, }, }); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs index f46f70f3..311a0f8a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs @@ -24,22 +24,20 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// -/// Verifies the equipment-tag native-alarm routing wired into -/// (Phase B WS-4c, the LIVE-CONDITION half): a driver child publishes a native alarm transition as -/// keyed by the alarm source's -/// SourceNodeId (the equipment tag's wire-ref FullName), but the materialised condition -/// lives at a FOLDER-SCOPED NodeId ({equipmentId}/{folderPath}/{name}). After an apply, the -/// host's _alarmNodeIdByDriverRef map (built only from alarm-bearing EquipmentTags) resolves -/// (DriverInstanceId, SourceNodeId) to that NodeId, the NativeAlarmProjector projects -/// the transition into a full AlarmConditionSnapshot, and ForwardNativeAlarm Tells the -/// publish actor an — the same message scripted -/// alarms use. +/// v3 Batch 4 (B4-WP4) — the equipment-tag native-alarm routing wired into +/// re-expressed for the v3 raw-condition model. A native alarm is a SINGLE +/// Part 9 condition materialised at the RAW tag (ConditionId = its RawPath, Raw realm) — NOT a folder-scoped +/// equipment-tag NodeId. After an apply, the host's _alarmNodeIdByDriverRef map (built from the +/// alarm-bearing composition.RawTags) resolves (DriverInstanceId, RawPath) to the raw +/// condition NodeId, the NativeAlarmProjector projects the transition into a full +/// AlarmConditionSnapshot, and ForwardNativeAlarm Tells the publish actor a single +/// (Raw realm) — the fan-out to referencing equipment +/// happens at the SDK-notifier level (WP4 node-manager wiring), NOT by re-reporting per root. /// /// -/// Mirrors the value-routing harness in DriverHostActorLiveValueTests: the seeded artifact -/// carries the Namespaces / DriverInstances / Tags arrays, with each alarm -/// tag's TagConfig carrying an alarm object so -/// DeploymentArtifact.ExtractTagAlarm projects a non-null +/// Mirrors the v3 value-routing harness in DriverHostActorLiveValueTests: the seeded artifact +/// carries the v3 raw-tag chain (RawFolder → DriverInstance(RawFolderId) → Device → Tag) with the alarm +/// tag's TagConfig carrying an alarm object so the composer projects a non-null /// EquipmentTagAlarmInfo. The OPC UA sink + dependency mux are injected as TestProbes. /// /// @@ -49,25 +47,26 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly DateTime Ts = new(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc); - /// A native alarm RAISE whose ConditionId equals the alarm tag's FullName lands on - /// the condition's folder-scoped NodeId (here eq-1/temp_hi) as an + // The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "Modbus" / Device "dev1" / Tag. + private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi"; + + /// A native alarm RAISE whose ConditionId equals the alarm tag's RawPath (the v3 wire-ref) + /// lands on the RAW condition NodeId (== the RawPath, Raw realm) as an /// with State.Active == true. The event carries a - /// production-shaped SourceNodeId (the bare owning object, distinct from ConditionId) so the - /// lookup is proven to key on ConditionId, not SourceNodeId. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] - public void Native_alarm_raise_routes_to_folder_scoped_condition_NodeId_active() + /// distinct SourceNodeId so the lookup is proven to key on ConditionId, not + /// SourceNodeId. + [Fact] + public void Native_alarm_raise_routes_to_raw_condition_NodeId_active() { var db = NewInMemoryDbFactory(); - // One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "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, publish) = SpawnHostAndApply(db, deploymentId); actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key - ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key) + ConditionId: AlarmRawPath, // the v3 wire-ref RawPath = the lookup key AlarmType: "OffNormalAlarm", Message: "temperature high", Severity: AlarmSeverity.High, @@ -75,7 +74,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase Kind: AlarmTransitionKind.Raise))); var update = publish.ExpectMsg(TimeSpan.FromSeconds(5)); - update.AlarmNodeId.ShouldBe("eq-1/temp_hi"); + update.AlarmNodeId.ShouldBe(AlarmRawPath); + update.Realm.ShouldBe(AddressSpaceRealm.Raw); update.State.Active.ShouldBeTrue(); update.State.Acknowledged.ShouldBeFalse(); update.TimestampUtc.ShouldBe(Ts); @@ -87,35 +87,31 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase public void Unknown_alarm_ref_produces_no_AlarmStateUpdate() { 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 (actor, publish) = SpawnHostAndApply(db, deploymentId); actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), - SourceNodeId: "Temp", // owning object exists, but the condition ref below is unmapped - ConditionId: "NoSuch.HiHi", // dotted ref not in the alarm map ⇒ drop + SourceNodeId: "Temp", + ConditionId: "Plant/Modbus/dev1/no_such", // unmapped RawPath ⇒ drop AlarmType: "OffNormalAlarm", Message: "nope", Severity: AlarmSeverity.Low, SourceTimestampUtc: Ts, Kind: AlarmTransitionKind.Raise))); - // No alarm-condition NodeId for ("drv-1","NoSuch.Alarm") → nothing reaches the sink. publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } - /// Primary (default/unset role) fan-out (Phase B WS-5): a native alarm RAISE on a known ref - /// publishes exactly one to the cluster alerts topic with - /// AlarmId = the folder-scoped condition NodeId, alongside the (ungated) OPC UA condition - /// update. No is sent, so the cached role is unknown ⇒ emit. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + /// Primary (default/unset role) fan-out: a native alarm RAISE on a known ref publishes exactly one + /// to the cluster alerts topic with AlarmId = the raw + /// condition NodeId (RawPath), alongside the (ungated) OPC UA condition update. + [Fact] public void Native_alarm_publishes_AlarmTransitionEvent_to_alerts_when_primary() { 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); @@ -124,8 +120,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), - SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key - ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key) + SourceNodeId: "Temp", + ConditionId: AlarmRawPath, AlarmType: "OffNormalAlarm", Message: "temperature high", Severity: AlarmSeverity.High, @@ -134,38 +130,31 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase // The OPC UA condition update is UNGATED — it must arrive. var update = publish.ExpectMsg(TimeSpan.FromSeconds(5)); - update.AlarmNodeId.ShouldBe("eq-1/temp_hi"); + update.AlarmNodeId.ShouldBe(AlarmRawPath); // Role unknown ⇒ default-emit: exactly one AlarmTransitionEvent on the alerts topic. var evt = alerts.ExpectMsg(TimeSpan.FromSeconds(5)); - evt.AlarmId.ShouldBe("eq-1/temp_hi"); // the folder-scoped condition NodeId - evt.EquipmentPath.ShouldBe("eq-1"); // from the alarm-bearing tag's EquipmentId - evt.AlarmName.ShouldBe("temp_hi"); // from the tag's Name + evt.AlarmId.ShouldBe(AlarmRawPath); // the raw condition NodeId (v3: ConditionId == RawPath) + evt.EquipmentPath.ShouldBe(AlarmRawPath); // v3: the alarm meta keys the display off the RawPath + evt.AlarmName.ShouldBe("temp_hi"); // from the raw tag's Name evt.TransitionKind.ShouldBe("Activated"); // native Kind → canonical EmissionKind vocabulary (Raise → Activated) evt.AlarmTypeName.ShouldBe("OffNormalAlarm"); // the tag's alarm AlarmType evt.Severity.ShouldBe(700); // AlarmSeverity.High → projector 700 evt.Message.ShouldBe("temperature high"); evt.User.ShouldBe(string.Empty); // no operator comment ⇒ device-origin (empty user) - // This tag's TagConfig.alarm carries no historizeToAveva key ⇒ null ⇒ the HistorianAdapterActor - // gate (historizeToAveva is not false) still historizes (default-on). Only an explicit false - // suppresses the durable AVEVA row — see Native_alarm_historizeToAveva_false_threads_through. - evt.HistorizeToAveva.ShouldBeNull(); + evt.HistorizeToAveva.ShouldBeNull(); // absent historizeToAveva key ⇒ null ⇒ default-on alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one } - /// Native-alarm HistorizeToAveva opt-out (Task 3): a tag whose TagConfig.alarm carries + /// Native-alarm HistorizeToAveva opt-out: a tag whose TagConfig.alarm carries /// historizeToAveva: false publishes its with - /// HistorizeToAveva == false, so the runtime's HistorianAdapterActor gate - /// (historizeToAveva is not false) suppresses the durable AVEVA write — the same opt-out the - /// scripted-alarm plan flag drives. The live /alerts fan-out is unaffected (the transition still - /// publishes; only the durable row is gated downstream). - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + /// HistorizeToAveva == false, so the runtime's HistorianAdapterActor gate suppresses the + /// durable AVEVA write. The live /alerts fan-out is unaffected. + [Fact] public void Native_alarm_historizeToAveva_false_threads_through() { var db = NewInMemoryDbFactory(); - var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, - Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi", - historizeToAveva: false); + var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: false); var alerts = CreateTestProbe(); SubscribeToAlerts(alerts); @@ -175,7 +164,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", - ConditionId: "Temp.HiHi", + ConditionId: AlarmRawPath, AlarmType: "OffNormalAlarm", Message: "temperature high", Severity: AlarmSeverity.High, @@ -183,21 +172,17 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase Kind: AlarmTransitionKind.Raise))); var evt = alerts.ExpectMsg(TimeSpan.FromSeconds(5)); - evt.AlarmId.ShouldBe("eq-1/temp_hi"); - // The explicit opt-out rides onto the transition ⇒ the historian gate suppresses the durable row. + evt.AlarmId.ShouldBe(AlarmRawPath); evt.HistorizeToAveva.ShouldBe(false); } - /// Native-alarm HistorizeToAveva opt-IN (Task 3): an explicit historizeToAveva: true - /// rides through as true (distinct from the absent ⇒ null default-on case) so an operator who - /// deliberately opts in is recorded as such on the transition. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + /// Native-alarm HistorizeToAveva opt-IN: an explicit historizeToAveva: true rides through as + /// true (distinct from the absent ⇒ null default-on case). + [Fact] public void Native_alarm_historizeToAveva_true_threads_through() { var db = NewInMemoryDbFactory(); - var deploymentId = SeedDeploymentWithAlarmTag(db, RevA, - Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi", - historizeToAveva: true); + var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: true); var alerts = CreateTestProbe(); SubscribeToAlerts(alerts); @@ -207,7 +192,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), SourceNodeId: "Temp", - ConditionId: "Temp.HiHi", + ConditionId: AlarmRawPath, AlarmType: "OffNormalAlarm", Message: "temperature high", Severity: AlarmSeverity.High, @@ -218,16 +203,14 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase evt.HistorizeToAveva.ShouldBe(true); } - /// Secondary suppression (Phase B WS-5): when the cached local role is Secondary the host - /// MUST still write the local OPC UA condition node (ungated — keeps the standby's address space warm - /// for failover) but MUST NOT publish the cluster-wide alerts transition (the Primary publishes - /// the single fleet-wide copy). - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + /// Secondary suppression: when the cached local role is Secondary the host MUST still write the + /// local OPC UA condition node (ungated — keeps the standby warm) but MUST NOT publish the cluster-wide + /// alerts transition (the Primary publishes the single fleet-wide copy). + [Fact] public void Secondary_node_suppresses_alerts_publish_but_still_updates_condition() { 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); @@ -239,8 +222,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( new StubAlarmHandle(), - SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key - ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key) + SourceNodeId: "Temp", + ConditionId: AlarmRawPath, AlarmType: "OffNormalAlarm", Message: "temperature high", Severity: AlarmSeverity.High, @@ -249,7 +232,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase // The OPC UA condition update is UNGATED — it must still arrive on the secondary. var update = publish.ExpectMsg(TimeSpan.FromSeconds(5)); - update.AlarmNodeId.ShouldBe("eq-1/temp_hi"); + update.AlarmNodeId.ShouldBe(AlarmRawPath); update.State.Active.ShouldBeTrue(); // The cluster-wide alerts publish is gated off on the secondary. @@ -257,27 +240,23 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase } /// A native alarm whose AlarmEventArgs.OperatorComment is set flows through - /// DriverHostActor.ForwardNativeAlarm into the published : - /// Comment carries the operator string and User is "device" (a non-null comment - /// signals the upstream alarm system provided an operator origin, but without a specific user identity). - /// - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + /// ForwardNativeAlarm into the published : Comment carries + /// the operator string and User is "device". + [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(), Tags = new[] { new { TagId = "tag-0", - EquipmentId = Equip, - DriverInstanceId = Driver, - Name, - FolderPath = Folder, + DeviceId = $"{Driver}:dev1", + TagGroupId = (string?)null, + Name = Tag, DataType = "Boolean", - TagConfig = JsonSerializer.Serialize(new - { - FullName, - alarm, - }), + AccessLevel = 0, // TagAccessLevel.Read + TagConfig = JsonSerializer.Serialize(new { alarm }), }, }, }); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs index a38b2319..e6001692 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs @@ -224,6 +224,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase public void RebuildAddressSpace() { /* recorded via span */ } /// Announces a NodeAdded model-change (stub implementation). 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.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs index 5883c093..c5e07ecc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs @@ -125,6 +125,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase 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() => throw new InvalidOperationException("simulated rebuild fault"); 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") { } } @@ -138,6 +139,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase 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") { } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index 6b8f6a87..79845f33 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -473,6 +473,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// Records a NodeAdded model-change announcement. /// The node under which discovered nodes were added. public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.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") { } /// Records a surgical in-place tag-attribute update (always succeeds in this recording sink). 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.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index 3f73107a..137b607f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -647,6 +647,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase /// 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") { } }