From a1a56e22bb6967e3bf5b2af51ae89dafa8e13f8c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 09:03:29 -0400 Subject: [PATCH 01/14] =?UTF-8?q?feat(v3-batch4):=20contracts=20=E2=80=94?= =?UTF-8?q?=20dual-namespace=20URIs,=20V3NodeIds,=20AddressSpaceRealm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 4 contracts commit (coordinator lands before Wave A fan-out). Purely additive so wave agents branch from a green base: - AddressSpaceRealm enum (Raw | Uns) — the explicit realm discriminator that travels alongside a node's s= identifier at every sink seam (WP2 adds it to IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink; WP3 threads it). - V3NodeIds — the two namespace URI constants (https://zb.com/otopcua/raw, https://zb.com/otopcua/uns, replacing the single .../ns) + Raw() pass-through (s= id == RawPath) + Uns()/UnsChild() slash-joined Area/Line/Equipment[/Eff] builders, both sharing RawPaths.Separator + ordinal segment validation. Placement note: URIs live in V3NodeIds (Commons) rather than beside the retired OtOpcUaNodeManager.DefaultNamespaceUri so runtime + tests reference them without depending on OpcUaServer (single-source-of-truth, §5). WP2 rewires the node manager to register both via V3NodeIds.RawNamespaceUri/UnsNamespaceUri. EquipmentNodeIds retirement is NOT in this commit: its callers are the applier + runtime (DriverHostActor/VirtualTagHostActor/DiscoveredNodeMapper), all WP3-owned (the composer does not use it) — deleting it here would red the base. WP3 sweeps it in Wave B. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../OpcUa/AddressSpaceRealm.cs | 18 +++ .../OpcUa/V3NodeIds.cs | 110 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AddressSpaceRealm.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AddressSpaceRealm.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AddressSpaceRealm.cs new file mode 100644 index 00000000..2d0d6169 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AddressSpaceRealm.cs @@ -0,0 +1,18 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +/// +/// Which of the two v3 OPC UA namespaces a node lives in. Carried explicitly alongside a +/// node's s= identifier at every address-space sink seam so the namespace is chosen at +/// the call site, never parsed back out of the id string (explicit beats inferred). +/// maps each realm to its namespace URI. +/// +public enum AddressSpaceRealm +{ + /// The device-oriented Raw tree (Folder→Driver→Device→TagGroup→Tag); a node's + /// s= id is its RawPath. + Raw, + + /// The equipment-oriented UNS tree (Area→Line→Equipment→signal); a node's s= + /// id is the slash-joined Area/Line/Equipment[/EffectiveName]. + Uns, +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs new file mode 100644 index 00000000..f6296c7a --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs @@ -0,0 +1,110 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +/// +/// v3 dual-namespace NodeId + namespace-URI authority. Two OPC UA namespaces expose the same +/// underlying values under two identity schemes: +/// +/// Raw (): the device-oriented tree +/// Folder→Driver→Device→TagGroup→Tag. A node's s= identifier IS its +/// RawPath — folders/drivers/devices/groups included (each keys +/// on its own RawPath prefix). +/// Uns (): the equipment-oriented tree +/// Area→Line→Equipment→signal. A node's s= identifier is the slash-joined +/// Area/Line/Equipment[/EffectiveName]. +/// +/// These replace the single https://zb.com/otopcua/ns namespace and the retired +/// EquipmentNodeIds ({equipmentId}/{folderPath}/{name}) scheme. Realm travels alongside +/// the identifier as an at every sink seam — the namespace is +/// never parsed back out of the id string. Both schemes share +/// and its ordinal, case-sensitive segment charset so identity is enforced in one place. +/// +public static class V3NodeIds +{ + /// The Raw namespace URI (device-oriented subtree). + public const string RawNamespaceUri = "https://zb.com/otopcua/raw"; + + /// The UNS namespace URI (equipment-oriented subtree). + public const string UnsNamespaceUri = "https://zb.com/otopcua/uns"; + + /// The namespace URI for a realm. + /// The address-space realm. + /// The realm's namespace URI. + /// Unknown realm. + public static string NamespaceUri(AddressSpaceRealm realm) => realm switch + { + AddressSpaceRealm.Raw => RawNamespaceUri, + AddressSpaceRealm.Uns => UnsNamespaceUri, + _ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."), + }; + + // ----- Raw realm ----- + + /// + /// The Raw-namespace s= identifier for a raw node — identical to its + /// RawPath (folders, drivers, devices, groups, and tags all key + /// on their own RawPath). A pass-through seam so call sites read intent rather than a bare + /// string; the argument is expected to already be a RawPaths-built path. + /// + /// The (already-built) RawPath. + /// The Raw-namespace s= identifier (== ). + public static string Raw(string rawPath) + { + ArgumentException.ThrowIfNullOrEmpty(rawPath); + return rawPath; + } + + // ----- Uns realm ----- + + /// + /// The UNS-namespace s= identifier for a folder or variable, slash-joined from + /// ordered segments (Area, then Line, then Equipment, then an optional EffectiveName). + /// Every segment is validated via (no embedded + /// separator, no leading/trailing whitespace) so a UNS id round-trips like a RawPath. + /// + /// The ordered, non-empty segments (Area → leaf). + /// The slash-joined UNS s= identifier. + /// A segment is invalid, or the sequence is empty. + public static string Uns(params string[] segments) => UnsFromSegments(segments); + + /// Build a UNS s= identifier from ordered segments. See . + /// The ordered, non-empty segments (Area → leaf). + /// The slash-joined UNS s= identifier. + public static string Uns(IEnumerable segments) => UnsFromSegments(segments); + + /// + /// Append a child segment (a Line under an Area, an Equipment under a Line, an + /// EffectiveName under an Equipment) to an already-built UNS path. + /// + /// The parent UNS path (assumed already valid). + /// The child segment to append. + /// The combined UNS s= identifier. + /// The child segment is invalid, or the parent is blank. + public static string UnsChild(string parentUnsPath, string childSegment) + { + ArgumentException.ThrowIfNullOrEmpty(parentUnsPath); + var error = RawPaths.ValidateSegment(childSegment); + if (error is not null) + throw new ArgumentException($"Invalid UNS NodeId segment ('{childSegment}'): {error}", nameof(childSegment)); + + return parentUnsPath + RawPaths.Separator + childSegment; + } + + private static string UnsFromSegments(IEnumerable segments) + { + ArgumentNullException.ThrowIfNull(segments); + var list = segments as IReadOnlyList ?? segments.ToList(); + if (list.Count == 0) + throw new ArgumentException("A UNS NodeId must have at least one segment.", nameof(segments)); + + for (var i = 0; i < list.Count; i++) + { + var error = RawPaths.ValidateSegment(list[i]); + if (error is not null) + throw new ArgumentException($"Invalid UNS NodeId segment at index {i} ('{list[i]}'): {error}", nameof(segments)); + } + + return string.Join(RawPaths.Separator, list); + } +} From f4f3e17e3ed0176e35f5bfa3f710afca6490abbf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 09:30:12 -0400 Subject: [PATCH 02/14] feat(v3-batch4-wp1): composer/planner emit both Raw + UNS subtrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un-darken the address-space composition for the v3 dual namespace. Composer (AddressSpaceComposer): - Raw subtree: RawContainerNode (Folder/Driver/Device/TagGroup as Object/Folder nodes, keyed s=, parent-before-child) + RawTagPlan (raw tag Variables keyed (realm=Raw, s=) carrying DataType / writable / historize+historian tagname / array shape). Native-alarm intent attaches at the RAW tag (ConditionId = RawPath) with the list of referencing equipment UNS paths (one alarm at the raw tag, not one per equipment). - UNS subtree: UnsReferenceVariable per UnsTagReference, keyed (realm=Uns, s=///), carrying its backing RawPath (Organizes UNS->Raw target + fan-out) and inheriting DataType/AccessLevel from the backing raw tag. Effective name = DisplayNameOverride else backing raw Tag.Name. - All RawPaths flow through one shared RawTopology (RawPathResolver + memoised container paths), byte-parity with EquipmentReferenceMap. - Every new node carries an AddressSpaceRealm (so WP3's applier picks the namespace at the call site). Additive/defaulted model changes only — the un-migrated AddressSpaceApplier still compiles. Planner (AddressSpacePlanner.Compute) + AddressSpacePlan: - Per-realm diff sets: RawContainers + RawTags keyed by RawPath (NodeId) so a rename = remove+add; UnsReferenceVariables keyed by the stable UnsTagReferenceId so a backing-tag rename re-points (BackingRawPath moves, NodeId stable) and a display-name-override edit is UNS-only. - PINNED: raw-tag rename = remove+add in Raw AND re-point in UNS. Classifier folds the new sets in (adds->PureAdd, removes->PureRemove, changed->Rebuild safe default). Tests: AddressSpaceComposerDualNamespaceTests (7), AddressSpacePlannerDualNamespaceTests (8, incl. the rename pin), AddressSpaceChangeClassifierDualNamespaceTests (8); reactivated EquipmentNamespaceMaterializationTests' Batch-4-pending case, re-authored to drive the composer over the seeded config (the artifact-decode seam is WP3). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../AddressSpaceChangeClassifier.cs | 18 +- .../AddressSpaceComposer.cs | 585 +++++++++++++++++- .../AddressSpacePlan.cs | 88 +++ .../EquipmentNamespaceMaterializationTests.cs | 101 +-- ...SpaceChangeClassifierDualNamespaceTests.cs | 103 +++ .../AddressSpaceComposerDualNamespaceTests.cs | 208 +++++++ .../AddressSpacePlannerDualNamespaceTests.cs | 234 +++++++ 7 files changed, 1253 insertions(+), 84 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierDualNamespaceTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerDualNamespaceTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs index 695ef436..8a05bc74 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs @@ -53,10 +53,16 @@ public static class AddressSpaceChangeClassifier // 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment / // ChangedAlarms are always node-affecting; a changed tag is node-affecting UNLESS it is - // surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. Evaluated - // BEFORE the add/remove split so a non-surgical change alongside pure adds still rebuilds. + // surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. The v3 + // Batch-4 Raw/UNS Changed sets (container re-shape, raw-tag attribute/alarm edit, UNS re-point / + // display-name-override) route to Rebuild as the safe default — WP3's applier owns any surgical + // in-place refinement for them. Evaluated BEFORE the add/remove split so a non-surgical change + // alongside pure adds still rebuilds. if (plan.ChangedEquipment.Count > 0 || plan.ChangedAlarms.Count > 0 || + plan.ChangedRawContainers.Count > 0 || + plan.ChangedRawTags.Count > 0 || + plan.ChangedUnsReferenceVariables.Count > 0 || plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) || plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d))) { @@ -68,10 +74,14 @@ public static class AddressSpaceChangeClassifier // add/remove split below and leave a driver-only plan classified AttributeOnly. var hasAdds = plan.AddedEquipment.Count + plan.AddedAlarms.Count + - plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count > 0; + plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count + + plan.AddedRawContainers.Count + plan.AddedRawTags.Count + + plan.AddedUnsReferenceVariables.Count > 0; var hasRemoves = plan.RemovedEquipment.Count + plan.RemovedAlarms.Count + - plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count > 0; + plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count + + plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + + plan.RemovedUnsReferenceVariables.Count > 0; // 3 — additions AND removals. if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs index 24d46012..c770e863 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; @@ -53,8 +54,163 @@ public sealed record AddressSpaceComposition( /// constructor + call site keeps compiling unchanged. /// public IReadOnlyList EquipmentScriptedAlarms { get; init; } = Array.Empty(); + + // ----- v3 Batch 4 dual-namespace address space (un-darkened) ----- + + /// + /// The Raw subtree's container nodes (Folder → Driver → Device → TagGroup) as + /// Object/Folder nodes in the namespace. Each + /// carries its s=<RawPath> NodeId and its parent NodeId ( = cluster + /// root) so AddressSpaceApplier can materialise parents-before-children. Sorted by NodeId + /// (ordinal, so a parent precedes each child). Init-only, defaults empty so every existing constructor + /// + call site keeps compiling unchanged. + /// + public IReadOnlyList RawContainers { get; init; } = Array.Empty(); + + /// + /// The Raw subtree's tag Variable nodes — one per raw — keyed + /// (realm=Raw, s=<RawPath>). The RawPath IS the node identity + the single value source + /// the driver binds; every referencing UNS variable fans out from it. Carries DataType / writable / + /// historize + array shape, the optional native-alarm intent (attached at the RAW tag — + /// ConditionId = RawPath), and the list of referencing equipment UNS folder paths (so WP4 wires the + /// multi-notifier alarm). Init-only, defaults empty. + /// + public IReadOnlyList RawTags { get; init; } = Array.Empty(); + + /// + /// The UNS subtree's reference Variable nodes — one per — keyed + /// (realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>). Each carries + /// its backing raw tag's RawPath (the Organizes UNS→Raw target + the fan-out source) plus the + /// inherited DataType / writable from that raw tag. UNS nodes never bind a driver directly — they fan + /// out from the raw node. Init-only, defaults empty. + /// + public IReadOnlyList UnsReferenceVariables { get; init; } = Array.Empty(); } +/// Which Raw-tree container a is (all are Object/Folder +/// nodes; the kind lets the applier pick the OPC UA type + reference). +public enum RawNodeKind +{ + /// A driver-organizing . + Folder, + + /// A . + Driver, + + /// A . + Device, + + /// A tag-organizing . + TagGroup, +} + +/// +/// One Raw-subtree container node (Folder / Driver / Device / TagGroup). is its +/// RawPath (== the ns=Raw s= id); is the parent container's +/// RawPath ( = directly under the cluster root). is always +/// — carried explicitly so the applier chooses the namespace at the +/// call site rather than inferring it. +/// +/// The container's RawPath (its ns=Raw s= identifier). +/// The parent container's RawPath, or for a cluster-root node. +/// The friendly browse name (the container's leaf Name). +/// Which container this is. +/// The address-space realm (always ). +public sealed record RawContainerNode( + string NodeId, + string? ParentNodeId, + string DisplayName, + RawNodeKind Kind, + AddressSpaceRealm Realm = AddressSpaceRealm.Raw); + +/// +/// One Raw-subtree tag Variable node. is the tag's RawPath — the single +/// identity string at every seam (the ns=Raw s= id, the driver fan-out/write key, the +/// historian default tagname, the native-alarm ConditionId). is the +/// owning TagGroup's RawPath (or the Device's when the tag sits directly under the device). +/// mirrors Tag.AccessLevel == ReadWrite. / +/// / / / +/// are parsed from Tag.TagConfig via +/// (the single byte-parity authority). +/// is the (possibly empty) set of UNS equipment-folder paths (Area/Line/Equipment) that reference +/// this tag — WP4 wires one notifier per path so the raw tag's single alarm condition fans to every +/// referencing equipment. is always . +/// A null means the historian tagname defaults to the RawPath (resolved +/// later, not here). +/// +public sealed record RawTagPlan( + string TagId, + string NodeId, + string? ParentNodeId, + string DriverInstanceId, + string Name, + string DataType, + bool Writable, + EquipmentTagAlarmInfo? Alarm, + IReadOnlyList ReferencingEquipmentPaths, + bool IsHistorized = false, + string? HistorianTagname = null, + bool IsArray = false, + uint? ArrayLength = null, + AddressSpaceRealm Realm = AddressSpaceRealm.Raw) +{ + /// + // Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an + // interface-typed list) BY REFERENCE, which would flag every raw tag as "changed" on every parse + // (fresh list instances). Compare it element-wise so a no-op redeploy diffs empty (mirrors + // EquipmentVirtualTagPlan). + public bool Equals(RawTagPlan? other) => + other is not null && + TagId == other.TagId && + NodeId == other.NodeId && + ParentNodeId == other.ParentNodeId && + DriverInstanceId == other.DriverInstanceId && + Name == other.Name && + DataType == other.DataType && + Writable == other.Writable && + EqualityComparer.Default.Equals(Alarm, other.Alarm) && + IsHistorized == other.IsHistorized && + HistorianTagname == other.HistorianTagname && + IsArray == other.IsArray && + ArrayLength == other.ArrayLength && + Realm == other.Realm && + ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId); + hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm); + hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength); + hash.Add(Realm); + foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal); + return hash.ToHashCode(); + } +} + +/// +/// One UNS-subtree reference Variable node projected from a . +/// is the slash-joined UNS path Area/Line/Equipment/EffectiveName (the +/// ns=UNS s= id); is DisplayNameOverride else the backing +/// raw tag's Name. is the backing raw tag's RawPath — the +/// Organizes UNS→Raw reference target AND the fan-out value source (the UNS node binds no driver; +/// it mirrors the raw node). / are inherited from the +/// backing raw tag. is the stable diff identity (a display-name-override +/// edit keeps the same id while the NodeId/effective-name changes; a backing-tag rename keeps the same id +/// + NodeId while re-points). is always +/// . +/// +public sealed record UnsReferenceVariable( + string UnsTagReferenceId, + string EquipmentId, + string NodeId, + string EffectiveName, + string BackingRawPath, + string DataType, + bool Writable, + AddressSpaceRealm Realm = AddressSpaceRealm.Uns); + public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName); public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName); @@ -301,12 +457,17 @@ public static class AddressSpaceComposer /// /// Composes the address space build plan from the v3 configuration entities. - /// v3 DARK address space (Batch 1): equipment-tag variable nodes do NOT materialize — - /// EquipmentTags is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's - /// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) + - /// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so - /// 's driver/device hooks are always null. This is the pure reference - /// mirror of DeploymentArtifact.ParseComposition (byte-parity target for the corpus tests). + /// v3 dual namespace (Batch 4): the composer emits BOTH subtrees. The + /// + + /// light up the device-oriented ns=Raw tree (Folder→Driver→Device→TagGroup→Tag, each tag keyed + /// s=<RawPath>); the light up + /// the equipment-oriented ns=UNS tree (one Variable per , keyed + /// s=<Area>/<Line>/<Equipment>/<EffectiveName>, carrying its backing RawPath + /// for the Organizes reference + fan-out). Native-alarm intents attach at the RAW tag + /// (ConditionId = RawPath) with the list of referencing equipment paths. The legacy + /// set stays EMPTY (the retired + /// equipment-namespace tag path); UNS folders + per-equipment VirtualTags + ScriptedAlarms are + /// unchanged. /// /// The UNS areas. /// The UNS lines. @@ -366,15 +527,19 @@ public static class AddressSpaceComposer .Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate)) .ToList(); - // v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out). + // v3: the retired equipment-namespace tag path stays empty — raw + UNS variable nodes now carry + // every value (see RawTags / UnsReferenceVariables below). var equipmentTags = Array.Empty(); - // Per-equipment {{equip}}/ reference map (effectiveName → backing-tag RawPath), built - // from the equipment's UnsTagReferences + the raw tags + the raw topology via the shared - // RawPathResolver — the same identity authority the artifact-decode mirror + the draft validator - // use, so the three agree byte-for-byte on the resolved RawPath. - var referenceMapByEquip = BuildEquipmentReferenceMap( - unsTagReferences, tags, rawFolders, driverInstances, devices, tagGroups); + // The shared raw topology (RawPathResolver over the folder/driver/device/group ancestry + the + // container RawPath maps). One authority for the Raw subtree emit, the UNS backing RawPaths, and the + // {{equip}} reference map — so the three agree byte-for-byte on every RawPath. + var topology = RawTopology.Build(rawFolders, driverInstances, devices, tagGroups); + + // Per-equipment {{equip}}/ reference map (effectiveName → backing-tag RawPath), built via + // the shared EquipmentReferenceMap over the SAME resolver — the same identity authority the + // artifact-decode mirror + the draft validator use. + var referenceMapByEquip = BuildEquipmentReferenceMap(unsTagReferences, tags, topology.Resolver); var emptyRefMap = (IReadOnlyDictionary) new Dictionary(StringComparer.Ordinal); @@ -451,45 +616,38 @@ public static class AddressSpaceComposer Enabled: a.Enabled)); } + // Raw subtree (device-oriented) + UNS subtree (equipment-oriented) — the Batch-4 dual namespace. + var (rawContainers, rawTags) = BuildRawSubtree( + rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences, + unsAreas, unsLines, equipment, topology); + var unsReferenceVariables = BuildUnsReferenceVariables( + unsTagReferences, tags, unsAreas, unsLines, equipment, topology); + return new AddressSpaceComposition(areas, lines, nodes, plans, alarms) { EquipmentTags = equipmentTags, EquipmentVirtualTags = equipmentVirtualTags, EquipmentScriptedAlarms = equipmentScriptedAlarms, + RawContainers = rawContainers, + RawTags = rawTags, + UnsReferenceVariables = unsReferenceVariables, }; } /// Build the per-equipment {{equip}}/<RefName> reference map from the v3 entities - /// via the shared + — the entity-side - /// mirror of DeploymentArtifact.BuildEquipmentReferenceMap (JSON side). Empty when no references - /// are supplied (every test / earlier caller that omits the reference data). + /// via the shared over the SHARED — the + /// entity-side mirror of DeploymentArtifact.BuildEquipmentReferenceMap (JSON side). Empty when no + /// references are supplied (every test / earlier caller that omits the reference data). private static IReadOnlyDictionary> BuildEquipmentReferenceMap( IReadOnlyList? unsTagReferences, IReadOnlyList? tags, - IReadOnlyList? rawFolders, - IReadOnlyList driverInstances, - IReadOnlyList? devices, - IReadOnlyList? tagGroups) + RawPathResolver resolver) { var refs = unsTagReferences ?? Array.Empty(); var tagRows = tags ?? Array.Empty(); if (refs.Count == 0 || tagRows.Count == 0) return new Dictionary>(StringComparer.Ordinal); - var folders = (rawFolders ?? Array.Empty()) - .GroupBy(f => f.RawFolderId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal); - var drivers = driverInstances - .GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal); - var deviceMap = (devices ?? Array.Empty()) - .GroupBy(d => d.DeviceId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal); - var groups = (tagGroups ?? Array.Empty()) - .GroupBy(t => t.TagGroupId, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal); - var resolver = new RawPathResolver(folders, drivers, deviceMap, groups); - var tagsById = tagRows .GroupBy(t => t.TagId, StringComparer.Ordinal) .ToDictionary( @@ -503,4 +661,363 @@ public static class AddressSpaceComposer tagsById, resolver); } + + /// + /// Emit the Raw subtree: the container nodes (Folder→Driver→Device→TagGroup) as + /// Object/Folder nodes, and the tag Variable nodes keyed (realm=Raw, s=RawPath). + /// A node whose RawPath cannot be resolved (broken/unknown ancestry, or an invalid segment) is + /// SKIPPED rather than faulting the whole compose (mirrors 's + /// null-not-throw policy). Both lists are sorted by NodeId ordinal so a parent precedes each child. + /// + private static (IReadOnlyList Containers, IReadOnlyList Tags) BuildRawSubtree( + IReadOnlyList? rawFolders, + IReadOnlyList driverInstances, + IReadOnlyList? devices, + IReadOnlyList? tagGroups, + IReadOnlyList? tags, + IReadOnlyList? unsTagReferences, + IReadOnlyList unsAreas, + IReadOnlyList unsLines, + IReadOnlyList equipment, + RawTopology topology) + { + var containers = new List(); + + foreach (var f in rawFolders ?? Array.Empty()) + { + if (!topology.FolderPaths.TryGetValue(f.RawFolderId, out var path)) continue; + var parent = f.ParentRawFolderId is not null + ? topology.FolderPaths.GetValueOrDefault(f.ParentRawFolderId) + : null; + containers.Add(new RawContainerNode(path, parent, f.Name, RawNodeKind.Folder)); + } + + foreach (var d in driverInstances) + { + if (!topology.DriverPaths.TryGetValue(d.DriverInstanceId, out var path)) continue; + var parent = d.RawFolderId is not null + ? topology.FolderPaths.GetValueOrDefault(d.RawFolderId) + : null; + containers.Add(new RawContainerNode(path, parent, d.Name, RawNodeKind.Driver)); + } + + foreach (var dev in devices ?? Array.Empty()) + { + if (!topology.DevicePaths.TryGetValue(dev.DeviceId, out var path)) continue; + var parent = topology.DriverPaths.GetValueOrDefault(dev.DriverInstanceId); + containers.Add(new RawContainerNode(path, parent, dev.Name, RawNodeKind.Device)); + } + + foreach (var g in tagGroups ?? Array.Empty()) + { + if (!topology.GroupPaths.TryGetValue(g.TagGroupId, out var path)) continue; + var parent = g.ParentTagGroupId is not null + ? topology.GroupPaths.GetValueOrDefault(g.ParentTagGroupId) + : topology.DevicePaths.GetValueOrDefault(g.DeviceId); + containers.Add(new RawContainerNode(path, parent, g.Name, RawNodeKind.TagGroup)); + } + + // TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the + // native-alarm multi-notifier. Distinct + ordinal-sorted for determinism. + var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment); + + var rawTags = new List(); + foreach (var t in tags ?? Array.Empty()) + { + var nodeId = topology.Resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name); + if (nodeId is null) continue; + var parent = t.TagGroupId is not null + ? topology.GroupPaths.GetValueOrDefault(t.TagGroupId) + : topology.DevicePaths.GetValueOrDefault(t.DeviceId); + var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty); + var intent = TagConfigIntent.Parse(t.TagConfig); + var alarm = intent.Alarm is { } a + ? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva) + : null; + var refEquipPaths = equipPathByTag.GetValueOrDefault(t.TagId, Array.Empty()); + + rawTags.Add(new RawTagPlan( + TagId: t.TagId, + NodeId: nodeId, + ParentNodeId: parent, + DriverInstanceId: driverInstanceId, + Name: t.Name, + DataType: t.DataType, + Writable: t.AccessLevel == TagAccessLevel.ReadWrite, + Alarm: alarm, + ReferencingEquipmentPaths: refEquipPaths, + IsHistorized: intent.IsHistorized, + HistorianTagname: intent.HistorianTagname, + IsArray: intent.IsArray, + ArrayLength: intent.ArrayLength)); + } + + containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); + rawTags.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); + return (containers, rawTags); + } + + /// + /// Emit the UNS reference Variables: one per whose backing raw tag + + /// equipment path resolve, keyed (realm=Uns, s=Area/Line/Equipment/EffectiveName) and carrying + /// the backing RawPath (Organizes target + fan-out) plus the inherited DataType / writable. A + /// reference whose backing tag is unknown, whose RawPath is unresolvable, or whose UNS path segments + /// are invalid is SKIPPED. Sorted by NodeId ordinal. + /// + private static IReadOnlyList BuildUnsReferenceVariables( + IReadOnlyList? unsTagReferences, + IReadOnlyList? tags, + IReadOnlyList unsAreas, + IReadOnlyList unsLines, + IReadOnlyList equipment, + RawTopology topology) + { + var refs = unsTagReferences ?? Array.Empty(); + if (refs.Count == 0) return Array.Empty(); + + var tagsById = (tags ?? Array.Empty()) + .GroupBy(t => t.TagId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment); + + var vars = new List(); + foreach (var r in refs.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal)) + { + if (!tagsById.TryGetValue(r.TagId, out var tag)) continue; + if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue; + var rawPath = topology.Resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name); + if (rawPath is null) continue; + var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!; + + string nodeId; + try + { + nodeId = V3NodeIds.UnsChild(equipPath, effectiveName); + } + catch (ArgumentException) + { + continue; // invalid effective-name segment — dropped (deploy gate rejects invalid names) + } + + vars.Add(new UnsReferenceVariable( + UnsTagReferenceId: r.UnsTagReferenceId, + EquipmentId: r.EquipmentId, + NodeId: nodeId, + EffectiveName: effectiveName, + BackingRawPath: rawPath, + DataType: tag.DataType, + Writable: tag.AccessLevel == TagAccessLevel.ReadWrite)); + } + + vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId)); + return vars; + } + + /// + /// EquipmentId → UNS folder path (Area/Line/Equipment, slash-joined via + /// ) for every equipment whose Area/Line ancestry resolves + whose three + /// segments are valid. Equipment with a broken ancestry or an invalid segment is absent. + /// + private static IReadOnlyDictionary BuildEquipmentFolderPaths( + IReadOnlyList unsAreas, + IReadOnlyList unsLines, + IReadOnlyList equipment) + { + var areaName = unsAreas + .GroupBy(a => a.UnsAreaId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal); + var line = unsLines + .GroupBy(l => l.UnsLineId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().Name), StringComparer.Ordinal); + + var byEquip = new Dictionary(StringComparer.Ordinal); + foreach (var e in equipment) + { + if (!line.TryGetValue(e.UnsLineId, out var ln)) continue; + if (!areaName.TryGetValue(ln.UnsAreaId, out var aName)) continue; + try + { + byEquip[e.EquipmentId] = V3NodeIds.Uns(aName, ln.Name, e.Name); + } + catch (ArgumentException) + { + // invalid Area/Line/Equipment segment — dropped (deploy gate rejects invalid names). + } + } + return byEquip; + } + + /// + /// TagId → distinct, ordinal-sorted list of referencing equipment UNS folder paths + /// (Area/Line/Equipment). Drives the native-alarm multi-notifier (WP4): the raw tag's single + /// alarm condition fans one event to each referencing equipment's UNS folder. A tag with no + /// resolvable reference is absent (⇒ an empty list at the call site). + /// + private static IReadOnlyDictionary> BuildReferencingEquipmentPathsByTag( + IReadOnlyList? unsTagReferences, + IReadOnlyList unsAreas, + IReadOnlyList unsLines, + IReadOnlyList equipment) + { + var refs = unsTagReferences ?? Array.Empty(); + if (refs.Count == 0) return new Dictionary>(StringComparer.Ordinal); + + var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment); + var byTag = new Dictionary>(StringComparer.Ordinal); + foreach (var r in refs) + { + if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue; + if (!byTag.TryGetValue(r.TagId, out var set)) + byTag[r.TagId] = set = new SortedSet(StringComparer.Ordinal); + set.Add(equipPath); + } + return byTag.ToDictionary( + kv => kv.Key, + kv => (IReadOnlyList)kv.Value.ToList(), + StringComparer.Ordinal); + } + + /// + /// The shared Raw-tree topology: a over the folder/driver/device/group + /// ancestry plus the resolved container RawPath maps (memoised) + the device→driver id map. Built + /// once per compose and reused for the Raw subtree, the UNS backing RawPaths, and the {{equip}} + /// reference map so every RawPath is produced by the single authority. + /// + private sealed class RawTopology + { + public required RawPathResolver Resolver { get; init; } + + /// RawFolderId → RawPath (absent when the ancestry is broken). + public required IReadOnlyDictionary FolderPaths { get; init; } + + /// DriverInstanceId → RawPath (absent when the ancestry is broken). + public required IReadOnlyDictionary DriverPaths { get; init; } + + /// DeviceId → RawPath (absent when the ancestry is broken). + public required IReadOnlyDictionary DevicePaths { get; init; } + + /// TagGroupId → RawPath (absent when the ancestry is broken). + public required IReadOnlyDictionary GroupPaths { get; init; } + + /// DeviceId → DriverInstanceId — the raw tag's owning driver (for RawTagPlan). + public required IReadOnlyDictionary DriverIdByDevice { get; init; } + + public static RawTopology Build( + IReadOnlyList? rawFolders, + IReadOnlyList driverInstances, + IReadOnlyList? devices, + IReadOnlyList? tagGroups) + { + var folderRows = (rawFolders ?? Array.Empty()) + .GroupBy(f => f.RawFolderId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal); + var driverRows = driverInstances + .GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal); + var deviceRows = (devices ?? Array.Empty()) + .GroupBy(d => d.DeviceId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal); + var groupRows = (tagGroups ?? Array.Empty()) + .GroupBy(t => t.TagGroupId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal); + + var resolver = new RawPathResolver(folderRows, driverRows, deviceRows, groupRows); + + // Container RawPaths — reuse the resolver for driver prefix + memoise folder/device/group chains. + var folderPaths = new Dictionary(StringComparer.Ordinal); + foreach (var id in folderRows.Keys) + ResolveFolder(id, folderRows, folderPaths, 0); + + var driverPaths = new Dictionary(StringComparer.Ordinal); + foreach (var id in driverRows.Keys) + { + var p = resolver.TryBuildDriverPrefix(id); + if (p is not null) driverPaths[id] = p; + } + + var devicePaths = new Dictionary(StringComparer.Ordinal); + foreach (var (id, dev) in deviceRows) + { + if (!driverPaths.TryGetValue(dev.DriverInstanceId, out var driverPrefix)) continue; + try { devicePaths[id] = RawPaths.Combine(driverPrefix, dev.Name); } + catch (ArgumentException) { /* invalid device name — dropped */ } + } + + var groupPaths = new Dictionary(StringComparer.Ordinal); + var groupDeviceById = (tagGroups ?? Array.Empty()) + .GroupBy(t => t.TagGroupId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().DeviceId, StringComparer.Ordinal); + foreach (var id in groupRows.Keys) + ResolveGroup(id, groupRows, groupDeviceById, devicePaths, groupPaths, 0); + + var driverIdByDevice = deviceRows.ToDictionary(kv => kv.Key, kv => kv.Value.DriverInstanceId, StringComparer.Ordinal); + + return new RawTopology + { + Resolver = resolver, + FolderPaths = folderPaths, + DriverPaths = driverPaths, + DevicePaths = devicePaths, + GroupPaths = groupPaths, + DriverIdByDevice = driverIdByDevice, + }; + } + + private const int MaxDepth = 256; + + private static string? ResolveFolder( + string id, + IReadOnlyDictionary rows, + Dictionary memo, + int depth) + { + if (depth > MaxDepth) return null; + if (memo.TryGetValue(id, out var cached)) return cached; + if (!rows.TryGetValue(id, out var row)) return null; + var parentPath = row.ParentId is null ? null : ResolveFolder(row.ParentId, rows, memo, depth + 1); + if (row.ParentId is not null && parentPath is null) return null; // broken parent chain + try + { + var path = RawPaths.Combine(parentPath, row.Name); + memo[id] = path; + return path; + } + catch (ArgumentException) { return null; } + } + + private static string? ResolveGroup( + string id, + IReadOnlyDictionary rows, + IReadOnlyDictionary groupDeviceById, + IReadOnlyDictionary devicePaths, + Dictionary memo, + int depth) + { + if (depth > MaxDepth) return null; + if (memo.TryGetValue(id, out var cached)) return cached; + if (!rows.TryGetValue(id, out var row)) return null; + + string? parentPath; + if (row.ParentId is not null) + { + parentPath = ResolveGroup(row.ParentId, rows, groupDeviceById, devicePaths, memo, depth + 1); + if (parentPath is null) return null; // broken parent chain + } + else + { + // Root group: parent is the owning device's RawPath. + if (!groupDeviceById.TryGetValue(id, out var deviceId) + || !devicePaths.TryGetValue(deviceId, out parentPath)) + return null; + } + + try + { + var path = RawPaths.Combine(parentPath, row.Name); + memo[id] = path; + return path; + } + catch (ArgumentException) { return null; } + } + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs index b75901a8..8b6cdd26 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs @@ -69,6 +69,46 @@ public sealed record AddressSpacePlan( /// public IReadOnlyList RenamedFolders { get; init; } = Array.Empty(); + /// + /// v3 Batch 4 — the Raw subtree's container diff (Folder/Driver/Device/TagGroup nodes), + /// keyed by (the RawPath). A rename of any container changes + /// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId + /// is immutable identity. Init-only, defaulting empty, so existing construction sites compile + /// unchanged (consistent with the EquipmentTag diff sets above). + /// + public IReadOnlyList AddedRawContainers { get; init; } = Array.Empty(); + /// + public IReadOnlyList RemovedRawContainers { get; init; } = Array.Empty(); + /// + public IReadOnlyList ChangedRawContainers { get; init; } = Array.Empty(); + + /// + /// v3 Batch 4 — the Raw subtree's tag diff, keyed by (the + /// RawPath). A raw tag rename changes its RawPath ⇒ remove(old NodeId)+add(new NodeId) here + /// (the immutable-NodeId rule); an attribute-only edit (historize / writable / array / alarm) keeps + /// the same NodeId ⇒ a . Init-only, defaulting empty. + /// + public IReadOnlyList AddedRawTags { get; init; } = Array.Empty(); + /// + public IReadOnlyList RemovedRawTags { get; init; } = Array.Empty(); + /// + public IReadOnlyList ChangedRawTags { get; init; } = Array.Empty(); + + /// + /// v3 Batch 4 — the UNS subtree's reference-Variable diff, keyed by + /// (the stable row id, NOT the NodeId). Keying on + /// the row id is what makes a backing-raw-tag rename a re-point (same reference row, same + /// effective name ⇒ same UNS NodeId, but — the + /// Organizes target + fan-out source — moves) rather than a remove+add; and a + /// display-name-override edit a (same row id, NodeId + effective name + /// change) with NO raw-side change. Init-only, defaulting empty. + /// + public IReadOnlyList AddedUnsReferenceVariables { get; init; } = Array.Empty(); + /// + public IReadOnlyList RemovedUnsReferenceVariables { get; init; } = Array.Empty(); + /// + public IReadOnlyList ChangedUnsReferenceVariables { get; init; } = Array.Empty(); + /// Gets a value indicating whether the composition plan contains no changes. public bool IsEmpty => AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 && @@ -76,6 +116,9 @@ public sealed record AddressSpacePlan( AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 && AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 && AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 && + AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 && + AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 && + AddedUnsReferenceVariables.Count == 0 && RemovedUnsReferenceVariables.Count == 0 && ChangedUnsReferenceVariables.Count == 0 && RenamedFolders.Count == 0; public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current); @@ -84,6 +127,16 @@ public sealed record AddressSpacePlan( public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current); public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current); + /// A Raw container node present in both snapshots (same RawPath) with ≥1 differing field. + public sealed record RawContainerDelta(RawContainerNode Previous, RawContainerNode Current); + /// A Raw tag present in both snapshots (same RawPath) with ≥1 differing attribute (historize / + /// writable / array / alarm / referencing-equipment set). A rename is NOT a delta — it is remove+add. + public sealed record RawTagDelta(RawTagPlan Previous, RawTagPlan Current); + /// A UNS reference variable present in both snapshots (same UnsTagReferenceId) with ≥1 differing + /// field — a backing-tag re-point (BackingRawPath moved) or a display-name-override edit (NodeId + + /// effective name changed). + public sealed record UnsReferenceDelta(UnsReferenceVariable Previous, UnsReferenceVariable Current); + /// One renamed UNS Area / Line folder: the stable folder /// (the area's UnsAreaId or line's UnsLineId, the exact /// NodeId MaterialiseHierarchy placed the folder at) and the @@ -139,6 +192,32 @@ public static class AddressSpacePlanner t => t.VirtualTagId, (a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b)); + // Raw subtree — containers keyed by RawPath (NodeId). A rename changes the RawPath, so it surfaces + // as remove(old)+add(new) here (an OPC UA NodeId is immutable identity). Attribute-only container + // changes (none today — a container carries only DisplayName == leaf name, which lives in the + // RawPath) fall to Changed, kept for completeness / future fields. + var (addedRawContainers, removedRawContainers, changedRawContainers) = DiffById( + previous.RawContainers, next.RawContainers, + c => c.NodeId, + (a, b) => new AddressSpacePlan.RawContainerDelta(a, b)); + + // Raw subtree — tags keyed by RawPath (NodeId). A rename ⇒ remove(old NodeId)+add(new NodeId); an + // attribute-only edit (historize / writable / array / alarm) ⇒ Changed. RawTagPlan overrides record + // equality to compare ReferencingEquipmentPaths element-wise so a no-op redeploy diffs empty. + var (addedRawTags, removedRawTags, changedRawTags) = DiffById( + previous.RawTags, next.RawTags, + t => t.NodeId, + (a, b) => new AddressSpacePlan.RawTagDelta(a, b)); + + // UNS subtree — reference variables keyed by the STABLE UnsTagReferenceId (NOT the NodeId). Keying on + // the row id is what makes a backing-raw-tag rename a re-point (same row + effective name ⇒ same UNS + // NodeId, but BackingRawPath moves) rather than a remove+add, and a display-name-override edit a + // Changed delta (same row id, NodeId + effective name changed) with no raw-side entry. + var (addedUnsRefs, removedUnsRefs, changedUnsRefs) = DiffById( + previous.UnsReferenceVariables, next.UnsReferenceVariables, + v => v.UnsTagReferenceId, + (a, b) => new AddressSpacePlan.UnsReferenceDelta(a, b)); + // UNS Area / Line renames: a folder whose stable id is unchanged but whose // DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY // change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId @@ -159,6 +238,15 @@ public static class AddressSpacePlanner AddedEquipmentVirtualTags = addedVTags, RemovedEquipmentVirtualTags = removedVTags, ChangedEquipmentVirtualTags = changedVTags, + AddedRawContainers = addedRawContainers, + RemovedRawContainers = removedRawContainers, + ChangedRawContainers = changedRawContainers, + AddedRawTags = addedRawTags, + RemovedRawTags = removedRawTags, + ChangedRawTags = changedRawTags, + AddedUnsReferenceVariables = addedUnsRefs, + RemovedUnsReferenceVariables = removedUnsRefs, + ChangedUnsReferenceVariables = changedUnsRefs, RenamedFolders = renamedFolders, }; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs index 03fd19ca..5e862830 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs @@ -4,10 +4,12 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Validation; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; @@ -15,15 +17,21 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// /// End-to-end deploy of a UNS folder hierarchy through the real ConfigComposer: seed a /// 1-area / 1-line / 1-equipment UNS plus a v3 raw-tag chain (RawFolder → DriverInstance → Device → -/// Tag), StartDeployment, then assert the deployment's persisted artifact decodes (via +/// Tag) + a projecting the raw tag into the equipment, StartDeployment, +/// then assert the deployment's persisted artifact decodes (via /// ). /// -/// v3 DARK address space (Batch 1): the composer + artifact deliberately emit an EMPTY -/// equipment-tag plan set — raw-tag variable nodes are lit up in Batch 4's dual-namespace UNS↔Raw -/// fan-out. So the live assertion here is the DARK contract: the Area/Line/Equipment folder nodes -/// decode with their friendly UNS names, and EquipmentTags is empty EVEN THOUGH a raw tag was -/// seeded. The full equipment-signal materialization (raw tag → EquipmentTag with FullName) is -/// preserved as a Batch-4-pending Skipped test below (the plan explicitly says keep it, don't delete). +/// v3 dual namespace (Batch 4): the AddressSpaceComposer un-darkens BOTH subtrees. The +/// case pins the +/// artifact-decode invariant that the retired equipment-namespace EquipmentTags set stays EMPTY +/// (values now flow through the raw + UNS variable nodes). The +/// case drives the +/// real AddressSpaceComposer over the SAME seeded config (loaded back from the deploy DB) and pins +/// the Batch-4 dual-tree: the seeded raw tag surfaces as a Raw-realm Variable keyed by its RawPath, and the +/// UnsTagReference surfaces as a UNS-realm Variable carrying that RawPath (the Organizes target + fan-out). +/// Note: the composer is what WP1 lights up; the artifact-decode seam +/// (DeploymentArtifact.ParseComposition) is migrated by WP3, so the dual-tree assertion runs through +/// the composer directly rather than the full deploy → artifact round-trip. /// /// /// The OPC UA address-space browse is exercised separately against a real SDK node manager in @@ -33,12 +41,6 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// public sealed class EquipmentNamespaceMaterializationTests { - private const string Batch4Pending = - "v3 dark address space: equipment-tag variable plans (raw tag → EquipmentTag with FullName) do " + - "not materialize until Batch 4 (dual-namespace UNS↔Raw fan-out). The composer + artifact emit an " + - "empty equipment-tag plan set this batch; re-enable this full-materialization assertion (re-authored " + - "against the UnsTagReference fan-out) when Batch 4 lights the raw/UNS variable nodes."; - private static CancellationToken Ct => TestContext.Current.CancellationToken; // Equipment.EquipmentId must equal DraftValidator.DeriveEquipmentId(EquipmentUuid) — the @@ -95,47 +97,49 @@ public sealed class EquipmentNamespaceMaterializationTests composition.EquipmentTags.ShouldBeEmpty(); } - /// BATCH-4-PENDING: the full equipment-signal materialization — the seeded raw tag decodes - /// (via the real ConfigComposer → ParseComposition) into an EquipmentTag carrying its FullName. Dark - /// until Batch 4's UNS↔Raw fan-out; preserved (not deleted) so re-enabling is a one-line change once - /// the equipment-tag plan set is lit up. - [Fact(Skip = Batch4Pending)] - public async Task Deploying_an_equipment_namespace_carries_the_signal_into_the_artifact() + /// BATCH-4 dual namespace: the real over the seeded config + /// (loaded back from the deploy DB) emits BOTH subtrees — the seeded raw tag as a Raw-realm Variable + /// keyed by its RawPath, and the UnsTagReference as a UNS-realm Variable carrying that RawPath (the + /// Organizes UNS→Raw target + fan-out source) and inheriting the raw tag's DataType. Drives the composer + /// directly (not the artifact-decode round-trip, which WP3 migrates). + [Fact] + public async Task Composing_the_seeded_config_emits_the_raw_tag_and_uns_reference_variables() { await using var harness = await TwoNodeClusterHarness.StartAsync(); await harness.SeedDefaultClusterAsync("c1"); await SeedUnsHierarchyAsync(harness); - await using var scope = harness.NodeA.Services.CreateAsyncScope(); - var client = scope.ServiceProvider.GetRequiredService(); + await using var db = await CreateDbAsync(harness); + var composition = AddressSpaceComposer.Compose( + await db.UnsAreas.AsNoTracking().ToListAsync(Ct), + await db.UnsLines.AsNoTracking().ToListAsync(Ct), + await db.Equipment.AsNoTracking().ToListAsync(Ct), + await db.DriverInstances.AsNoTracking().ToListAsync(Ct), + await db.ScriptedAlarms.AsNoTracking().ToListAsync(Ct), + unsTagReferences: await db.UnsTagReferences.AsNoTracking().ToListAsync(Ct), + tags: await db.Tags.AsNoTracking().ToListAsync(Ct), + rawFolders: await db.RawFolders.AsNoTracking().ToListAsync(Ct), + devices: await db.Devices.AsNoTracking().ToListAsync(Ct), + tagGroups: await db.TagGroups.AsNoTracking().ToListAsync(Ct)); - var result = await client.StartDeploymentAsync(createdBy: "alice@test", Ct); - result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, $"Deploy not accepted: {result.Message}"); - var deploymentId = result.DeploymentId!.Value.Value; + // Raw subtree: the seeded raw tag is a Raw-realm Variable keyed by its RawPath (Plant/Modbus/dev-1/Speed). + var rawTag = composition.RawTags.ShouldHaveSingleItem(); + rawTag.TagId.ShouldBe("tag-speed"); + rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw); + rawTag.NodeId.ShouldBe("Plant/Modbus/dev-1/Speed"); + rawTag.DataType.ShouldBe("Float"); - var artifact = Array.Empty(); - await WaitForAsync(async () => - { - await using var db = await CreateDbAsync(harness); - var d = await db.Deployments.AsNoTracking() - .FirstOrDefaultAsync(x => x.DeploymentId == deploymentId, Ct); - if (d is { ArtifactBlob.Length: > 0 }) - { - artifact = d.ArtifactBlob; - return true; - } - return false; - }, TimeSpan.FromSeconds(15)); + // UNS subtree: the UnsTagReference is a UNS-realm Variable carrying the backing RawPath + inherited type. + var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem(); + unsVar.Realm.ShouldBe(AddressSpaceRealm.Uns); + unsVar.EquipmentId.ShouldBe(EquipmentId); + unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed"); + unsVar.EffectiveName.ShouldBe("Speed"); + unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev-1/Speed"); // Organizes UNS→Raw + fan-out + unsVar.DataType.ShouldBe("Float"); - var composition = DeploymentArtifact.ParseComposition(artifact); - - // Batch 4: the raw tag surfaces as an EquipmentTag with FullName pulled from TagConfig. - var tag = composition.EquipmentTags.ShouldHaveSingleItem(); - tag.TagId.ShouldBe("tag-speed"); - tag.EquipmentId.ShouldBe(EquipmentId); - tag.Name.ShouldBe("Speed"); - tag.DataType.ShouldBe("Float"); - tag.FullName.ShouldBe("40001"); + // The retired equipment-namespace tag path stays empty (values flow through raw + UNS now). + composition.EquipmentTags.ShouldBeEmpty(); } private static async Task SeedUnsHierarchyAsync(TwoNodeClusterHarness harness) @@ -170,6 +174,11 @@ public sealed class EquipmentNamespaceMaterializationTests Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40001\"}", }); + // v3 Batch 4: the equipment projects the raw tag by reference (reference-only UNS). + db.UnsTagReferences.Add(new UnsTagReference + { + UnsTagReferenceId = "ref-speed", EquipmentId = EquipmentId, TagId = "tag-speed", + }); await db.SaveChangesAsync(Ct); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierDualNamespaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierDualNamespaceTests.cs new file mode 100644 index 00000000..28f758df --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierDualNamespaceTests.cs @@ -0,0 +1,103 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// v3 Batch 4 (B4-WP1) — the classifier folds the Raw + UNS subtree diff sets into its routing policy: +/// raw/uns adds count as adds (PureAdd), removes as removes (PureRemove), and any Changed raw/uns delta +/// routes to Rebuild (the safe default — WP3's applier owns surgical refinement). +/// +public sealed class AddressSpaceChangeClassifierDualNamespaceTests +{ + private static readonly RawContainerNode Folder = new("Plant", null, "Plant", RawNodeKind.Folder); + + private static RawTagPlan RawTag(string nodeId) => + new("t-1", nodeId, "Plant/Modbus1/PLC-A", "drv-1", "Speed", "Float", + Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty()); + + private static UnsReferenceVariable UnsRef(string nodeId) => + new("ref-1", "eq-1", nodeId, "Speed", "Plant/Modbus1/PLC-A/Speed", "Float", Writable: false); + + private static AddressSpacePlan Empty() => new( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty(), Array.Empty()); + + [Fact] + public void Added_raw_tag_only_is_PureAdd() + { + var plan = Empty() with { AddedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") } }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); + } + + [Fact] + public void Added_raw_container_only_is_PureAdd() + { + var plan = Empty() with { AddedRawContainers = new[] { Folder } }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); + } + + [Fact] + public void Added_uns_reference_only_is_PureAdd() + { + var plan = Empty() with { AddedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") } }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); + } + + [Fact] + public void Removed_raw_tag_only_is_PureRemove() + { + var plan = Empty() with { RemovedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") } }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); + } + + [Fact] + public void Removed_uns_reference_only_is_PureRemove() + { + var plan = Empty() with { RemovedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") } }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); + } + + [Fact] + public void Raw_rename_add_and_remove_is_AddRemoveMix() + { + var plan = Empty() with + { + RemovedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") }, + AddedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Velocity") }, + }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AddRemoveMix); + } + + [Fact] + public void Changed_raw_tag_is_Rebuild() + { + var plan = Empty() with + { + ChangedRawTags = new[] + { + new AddressSpacePlan.RawTagDelta( + RawTag("Plant/Modbus1/PLC-A/Speed"), + RawTag("Plant/Modbus1/PLC-A/Speed") with { IsHistorized = true }), + }, + }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); + } + + [Fact] + public void Uns_reference_repoint_is_Rebuild() + { + var plan = Empty() with + { + ChangedUnsReferenceVariables = new[] + { + new AddressSpacePlan.UnsReferenceDelta( + UnsRef("filling/line-1/station-1/Speed"), + UnsRef("filling/line-1/station-1/Speed") with { BackingRawPath = "Plant/Modbus1/PLC-A/Velocity" }), + }, + }; + AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs new file mode 100644 index 00000000..c22cdf79 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerDualNamespaceTests.cs @@ -0,0 +1,208 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// v3 Batch 4 (B4-WP1) — the composer un-darkens BOTH subtrees. Verifies the Raw subtree +/// (Folder→Driver→Device→TagGroup container nodes + raw-tag Variables keyed s=<RawPath>, +/// realm ) and the UNS subtree (one Variable per +/// keyed s=<Area>/<Line>/<Equipment>/<EffectiveName>, +/// realm , carrying its backing RawPath for the Organizes ref + +/// fan-out). Native-alarm intents attach at the RAW tag and carry the referencing equipment paths. +/// +public sealed class AddressSpaceComposerDualNamespaceTests +{ + // A raw chain: RawFolder "Plant" → Driver "Modbus1" → Device "PLC-A" → Group "Motors" → tags. + // Speed sits under the group; Status directly under the device; Levels is an array tag. + private static AddressSpaceComposition ComposeFullFixture() + { + var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant", + Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}", + }; + var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" }; + var group = new TagGroup { TagGroupId = "g1", DeviceId = "dev-1", Name = "Motors" }; + + var speed = new Tag + { + TagId = "t-speed", DeviceId = "dev-1", TagGroupId = "g1", Name = "Speed", + DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, + TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"speed_hist\"," + + "\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":700}}", + }; + var status = new Tag + { + TagId = "t-status", DeviceId = "dev-1", Name = "Status", + DataType = "Boolean", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", + }; + var levels = new Tag + { + TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", + DataType = "Int32", AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"isArray\":true,\"arrayLength\":8}", + }; + + var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; + var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" }; + + var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" }; + var refStatus = new UnsTagReference + { + UnsTagReferenceId = "ref-status", EquipmentId = "eq-1", TagId = "t-status", + DisplayNameOverride = "MachineStatus", + }; + + return AddressSpaceComposer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), + unsTagReferences: new[] { refSpeed, refStatus }, + tags: new[] { speed, status, levels }, + rawFolders: new[] { folder }, + devices: new[] { device }, + tagGroups: new[] { group }); + } + + /// The Raw subtree emits Folder/Driver/Device/TagGroup container nodes keyed by RawPath, each + /// realm=Raw, with parents-before-children ordering + correct parent NodeIds. + [Fact] + public void Raw_container_nodes_materialise_with_rawpath_nodeids_and_parents() + { + var c = ComposeFullFixture(); + + c.RawContainers.ShouldAllBe(n => n.Realm == AddressSpaceRealm.Raw); + + var folder = c.RawContainers.Single(n => n.Kind == RawNodeKind.Folder); + folder.NodeId.ShouldBe("Plant"); + folder.ParentNodeId.ShouldBeNull(); // cluster root + folder.DisplayName.ShouldBe("Plant"); + + var driver = c.RawContainers.Single(n => n.Kind == RawNodeKind.Driver); + driver.NodeId.ShouldBe("Plant/Modbus1"); + driver.ParentNodeId.ShouldBe("Plant"); + + var device = c.RawContainers.Single(n => n.Kind == RawNodeKind.Device); + device.NodeId.ShouldBe("Plant/Modbus1/PLC-A"); + device.ParentNodeId.ShouldBe("Plant/Modbus1"); + + var group = c.RawContainers.Single(n => n.Kind == RawNodeKind.TagGroup); + group.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors"); + group.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A"); + + // Sorted by NodeId ordinal ⇒ a parent always precedes each of its children. + c.RawContainers.Select(n => n.NodeId) + .ShouldBe(new[] { "Plant", "Plant/Modbus1", "Plant/Modbus1/PLC-A", "Plant/Modbus1/PLC-A/Motors" }); + } + + /// Raw tags are Variables keyed s=<RawPath> (realm=Raw), carrying DataType / + /// writable / historize + historian tagname / array shape, and hang under their group (or device). + [Fact] + public void Raw_tags_materialise_as_variables_keyed_by_rawpath_with_carried_attributes() + { + var c = ComposeFullFixture(); + + c.RawTags.ShouldAllBe(t => t.Realm == AddressSpaceRealm.Raw); + + var speed = c.RawTags.Single(t => t.TagId == "t-speed"); + speed.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed"); + speed.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors"); + speed.DriverInstanceId.ShouldBe("drv-modbus"); + speed.DataType.ShouldBe("Float"); + speed.Writable.ShouldBeTrue(); + speed.IsHistorized.ShouldBeTrue(); + speed.HistorianTagname.ShouldBe("speed_hist"); + + var status = c.RawTags.Single(t => t.TagId == "t-status"); + status.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Status"); // no group ⇒ under the device + status.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A"); + status.Writable.ShouldBeFalse(); + status.IsHistorized.ShouldBeFalse(); + + var levels = c.RawTags.Single(t => t.TagId == "t-levels"); + levels.IsArray.ShouldBeTrue(); + levels.ArrayLength.ShouldBe(8u); + } + + /// A native-alarm intent attaches at the RAW tag (ConditionId = RawPath) and carries the list + /// of referencing equipment UNS folder paths — one alarm at the raw tag, not one per equipment. + [Fact] + public void Native_alarm_attaches_at_raw_tag_with_referencing_equipment_paths() + { + var c = ComposeFullFixture(); + + var speed = c.RawTags.Single(t => t.TagId == "t-speed"); + speed.Alarm.ShouldNotBeNull(); + speed.Alarm!.AlarmType.ShouldBe("LimitAlarm"); + speed.Alarm.Severity.ShouldBe(700); + speed.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" }); + + // A non-alarm tag carries no condition + its own referencing-equipment list. + var status = c.RawTags.Single(t => t.TagId == "t-status"); + status.Alarm.ShouldBeNull(); + status.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" }); + + // An unreferenced tag has an empty referencing-equipment set. + var levels = c.RawTags.Single(t => t.TagId == "t-levels"); + levels.ReferencingEquipmentPaths.ShouldBeEmpty(); + } + + /// Each UnsTagReference emits a UNS Variable keyed + /// s=<Area>/<Line>/<Equipment>/<EffectiveName> (realm=Uns), carrying its backing + /// RawPath (the Organizes target + fan-out) and inheriting DataType + writable from the raw tag. Effective + /// name = DisplayNameOverride else the backing raw tag's Name. + [Fact] + public void Uns_reference_variables_carry_backing_rawpath_and_inherit_type_and_access() + { + var c = ComposeFullFixture(); + + c.UnsReferenceVariables.ShouldAllBe(v => v.Realm == AddressSpaceRealm.Uns); + + var speedRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-speed"); + speedRef.EffectiveName.ShouldBe("Speed"); // no override ⇒ backing raw tag Name + speedRef.NodeId.ShouldBe("filling/line-1/station-1/Speed"); + speedRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed"); // Organizes UNS→Raw + fan-out + speedRef.DataType.ShouldBe("Float"); + speedRef.Writable.ShouldBeTrue(); // inherited from the ReadWrite raw tag + + var statusRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-status"); + statusRef.EffectiveName.ShouldBe("MachineStatus"); // display-name override wins + statusRef.NodeId.ShouldBe("filling/line-1/station-1/MachineStatus"); + statusRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Status"); + statusRef.DataType.ShouldBe("Boolean"); + statusRef.Writable.ShouldBeFalse(); // inherited from the Read raw tag + } + + /// A composition with no raw/UNS inputs leaves all three new subtree sets empty (the legacy + /// convenience overloads + earlier callers keep working). + [Fact] + public void No_raw_or_uns_inputs_leaves_both_subtrees_empty() + { + var c = AddressSpaceComposer.Compose( + equipment: Array.Empty(), + driverInstances: Array.Empty(), + scriptedAlarms: Array.Empty()); + + c.RawContainers.ShouldBeEmpty(); + c.RawTags.ShouldBeEmpty(); + c.UnsReferenceVariables.ShouldBeEmpty(); + } + + /// The Raw + UNS emit is deterministic — repeated calls produce element-identical output + /// (RawTagPlan/UnsReferenceVariable compare by value, so ShouldBe is a content comparison). + [Fact] + public void Dual_subtree_emit_is_deterministic() + { + var a = ComposeFullFixture(); + var b = ComposeFullFixture(); + + a.RawContainers.ShouldBe(b.RawContainers); + a.RawTags.ShouldBe(b.RawTags); + a.UnsReferenceVariables.ShouldBe(b.UnsReferenceVariables); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerDualNamespaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerDualNamespaceTests.cs new file mode 100644 index 00000000..b63807f9 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerDualNamespaceTests.cs @@ -0,0 +1,234 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// v3 Batch 4 (B4-WP1) — the planner diffs per realm. Raw containers + raw tags diff by RawPath +/// (NodeId), so a rename is remove(old)+add(new); UNS reference variables diff by the stable +/// UnsTagReferenceId, so a backing-tag rename is a re-point (BackingRawPath moves, NodeId stable) +/// and a display-name-override edit is a UNS-only change with no raw delta. +/// The pinned invariant: a raw-tag rename = remove+add in Raw AND re-point in UNS. +/// +public sealed class AddressSpacePlannerDualNamespaceTests +{ + private const string RawFolderName = "Plant"; + private const string DriverName = "Modbus1"; + private const string DeviceName = "PLC-A"; + + // Compose a fixture with one raw tag (given name) under Plant/Modbus1/PLC-A and one UNS reference to it + // (with an optional display-name override so the effective name can be held stable across a raw rename). + private static AddressSpaceComposition Compose(string tagName, string? displayOverride) + { + var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = RawFolderName }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant", + Name = DriverName, DriverType = "Modbus", DriverConfig = "{}", + }; + var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = DeviceName, DeviceConfig = "{}" }; + var tag = new Tag + { + TagId = "t-speed", DeviceId = "dev-1", Name = tagName, + DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}", + }; + var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; + var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" }; + var reference = new UnsTagReference + { + UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "t-speed", + DisplayNameOverride = displayOverride, + }; + + return AddressSpaceComposer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), + unsTagReferences: new[] { reference }, + tags: new[] { tag }, + rawFolders: new[] { folder }, + devices: new[] { device }); + } + + /// A newly-added raw tag surfaces in AddedRawTags (keyed by RawPath); the plan is non-empty. + [Fact] + public void Added_raw_tag_goes_to_AddedRawTags() + { + var prev = Compose("Speed", displayOverride: null); + var next = Compose("Speed", displayOverride: null); + // prev without the tag: strip RawTags/UNS by composing an empty raw side. + var empty = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty()); + + var plan = AddressSpacePlanner.Compute(empty, next); + + plan.IsEmpty.ShouldBeFalse(); + plan.AddedRawTags.Select(t => t.NodeId).ShouldContain("Plant/Modbus1/PLC-A/Speed"); + plan.RemovedRawTags.ShouldBeEmpty(); + _ = prev; + } + + /// A disappeared raw tag surfaces in RemovedRawTags. + [Fact] + public void Removed_raw_tag_goes_to_RemovedRawTags() + { + var prev = Compose("Speed", displayOverride: null); + var empty = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty()); + + var plan = AddressSpacePlanner.Compute(prev, empty); + + plan.RemovedRawTags.Select(t => t.NodeId).ShouldContain("Plant/Modbus1/PLC-A/Speed"); + plan.AddedRawTags.ShouldBeEmpty(); + } + + /// A newly-added UNS reference surfaces in AddedUnsReferenceVariables. + [Fact] + public void Added_uns_reference_goes_to_AddedUnsReferenceVariables() + { + var empty = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty()); + var next = Compose("Speed", displayOverride: null); + + var plan = AddressSpacePlanner.Compute(empty, next); + + plan.AddedUnsReferenceVariables.Single().UnsTagReferenceId.ShouldBe("ref-1"); + plan.RemovedUnsReferenceVariables.ShouldBeEmpty(); + } + + /// A disappeared UNS reference surfaces in RemovedUnsReferenceVariables. + [Fact] + public void Removed_uns_reference_goes_to_RemovedUnsReferenceVariables() + { + var prev = Compose("Speed", displayOverride: null); + var empty = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty()); + + var plan = AddressSpacePlanner.Compute(prev, empty); + + plan.RemovedUnsReferenceVariables.Single().UnsTagReferenceId.ShouldBe("ref-1"); + plan.AddedUnsReferenceVariables.ShouldBeEmpty(); + } + + /// THE PIN: a raw-tag rename = remove+add in Raw AND re-point in UNS. With a display-name + /// override holding the effective name stable, the reference row is unchanged (same UnsTagReferenceId, + /// same UNS NodeId) but its backing NodeId moved ⇒ the UNS variable's Organizes target (BackingRawPath) + /// re-points via a ChangedUnsReferenceVariables delta. + [Fact] + public void Raw_rename_is_remove_add_in_raw_and_repoint_in_uns() + { + var prev = Compose("Speed", displayOverride: "Spd"); + var next = Compose("Velocity", displayOverride: "Spd"); // raw tag renamed; effective name held stable + + var plan = AddressSpacePlanner.Compute(prev, next); + + // Raw realm: remove the old RawPath, add the new one (immutable NodeId ⇒ not a Changed delta). + plan.RemovedRawTags.Select(t => t.NodeId).ShouldBe(new[] { "Plant/Modbus1/PLC-A/Speed" }); + plan.AddedRawTags.Select(t => t.NodeId).ShouldBe(new[] { "Plant/Modbus1/PLC-A/Velocity" }); + plan.ChangedRawTags.ShouldBeEmpty(); + + // UNS realm: the reference row is unchanged in identity + NodeId, but re-points to the new RawPath. + plan.AddedUnsReferenceVariables.ShouldBeEmpty(); + plan.RemovedUnsReferenceVariables.ShouldBeEmpty(); + var repoint = plan.ChangedUnsReferenceVariables.ShouldHaveSingleItem(); + repoint.Previous.UnsTagReferenceId.ShouldBe("ref-1"); + repoint.Current.UnsTagReferenceId.ShouldBe("ref-1"); + repoint.Previous.NodeId.ShouldBe(repoint.Current.NodeId); // effective name stable ⇒ NodeId stable + repoint.Previous.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Speed"); + repoint.Current.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Velocity"); + + // The raw container topology (Folder/Driver/Device) is untouched by a tag rename. + plan.AddedRawContainers.ShouldBeEmpty(); + plan.RemovedRawContainers.ShouldBeEmpty(); + plan.ChangedRawContainers.ShouldBeEmpty(); + } + + /// A display-name-override change is a UNS-only change (no raw delta): same reference row id, + /// the effective name + UNS NodeId move, the backing RawPath is unchanged. + [Fact] + public void Display_name_override_change_is_uns_only_no_raw_change() + { + var prev = Compose("Speed", displayOverride: "Spd"); + var next = Compose("Speed", displayOverride: "Speedy"); // only the override changed + + var plan = AddressSpacePlanner.Compute(prev, next); + + // No raw-side change whatsoever. + plan.AddedRawTags.ShouldBeEmpty(); + plan.RemovedRawTags.ShouldBeEmpty(); + plan.ChangedRawTags.ShouldBeEmpty(); + plan.AddedRawContainers.ShouldBeEmpty(); + plan.RemovedRawContainers.ShouldBeEmpty(); + plan.ChangedRawContainers.ShouldBeEmpty(); + + // UNS: same reference row id, effective name + NodeId changed, backing RawPath unchanged. + var delta = plan.ChangedUnsReferenceVariables.ShouldHaveSingleItem(); + delta.Previous.UnsTagReferenceId.ShouldBe("ref-1"); + delta.Previous.EffectiveName.ShouldBe("Spd"); + delta.Current.EffectiveName.ShouldBe("Speedy"); + delta.Previous.NodeId.ShouldNotBe(delta.Current.NodeId); + delta.Previous.BackingRawPath.ShouldBe(delta.Current.BackingRawPath); + plan.AddedUnsReferenceVariables.ShouldBeEmpty(); + plan.RemovedUnsReferenceVariables.ShouldBeEmpty(); + } + + /// An attribute-only raw-tag edit (historize toggle) keeps the same RawPath ⇒ a + /// ChangedRawTags delta (not remove+add). + [Fact] + public void Raw_tag_attribute_edit_keeps_nodeid_and_routes_to_ChangedRawTags() + { + var prev = Compose("Speed", displayOverride: null); + // next: same identity + name, but toggle historize on the raw tag. + var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = RawFolderName }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant", + Name = DriverName, DriverType = "Modbus", DriverConfig = "{}", + }; + var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = DeviceName, DeviceConfig = "{}" }; + var tag = new Tag + { + TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", + DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isHistorized\":true}", + }; + var next = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + new[] { driver }, Array.Empty(), + tags: new[] { tag }, rawFolders: new[] { folder }, devices: new[] { device }); + + var prevRawOnly = AddressSpaceComposer.Compose( + Array.Empty(), Array.Empty(), Array.Empty(), + new[] { driver }, Array.Empty(), + tags: new[] { new Tag { TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}" } }, + rawFolders: new[] { folder }, devices: new[] { device }); + + var plan = AddressSpacePlanner.Compute(prevRawOnly, next); + + plan.AddedRawTags.ShouldBeEmpty(); + plan.RemovedRawTags.ShouldBeEmpty(); + var changed = plan.ChangedRawTags.ShouldHaveSingleItem(); + changed.Previous.NodeId.ShouldBe(changed.Current.NodeId); + changed.Previous.IsHistorized.ShouldBeFalse(); + changed.Current.IsHistorized.ShouldBeTrue(); + _ = prev; + } + + /// Identical compositions diff to an empty plan across both realms (fresh list instances must + /// not spuriously flag RawTags/UnsReferenceVariables as changed). + [Fact] + public void Identical_dual_namespace_compositions_diff_to_empty() + { + var prev = Compose("Speed", displayOverride: "Spd"); + var next = Compose("Speed", displayOverride: "Spd"); + + var plan = AddressSpacePlanner.Compute(prev, next); + + plan.IsEmpty.ShouldBeTrue(); + } +} From 5534698d7097ae20472e2dc8c4e225198af061be Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 09:33:45 -0400 Subject: [PATCH 03/14] feat(v3-batch4-wp2): node manager dual-namespace + realm-aware sink surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register both v3 namespaces (Raw first, UNS second) on OtOpcUaNodeManager and thread an AddressSpaceRealm discriminator through every node-naming sink method so a bare node id is resolved to the correct namespace by realm — never parsed out of the id string. - IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink: every node-naming method gains `AddressSpaceRealm realm = AddressSpaceRealm.Uns` (transitional default so un-migrated WP3 call sites still compile; WP3/Wave B makes them explicit and removes the default). Null + SdkAddressSpaceSink impls updated; DeferredAddress- SpaceSink forwards realm through every method (the forwarding trap). - DeferredSinkForwardingReflectionTests: existing exhaustive-forwarding guard auto-covers the new signatures; added an explicit guard that every node-naming sink method carries an AddressSpaceRealm parameter (RebuildAddressSpace exempt). - OtOpcUaNodeManager: register RawNamespaceUri + UnsNamespaceUri; realm->namespace index via NamespaceIndexForRealm; all node maps (_variables/_folders/ _alarmConditions/_nativeAlarmNodeIds/_historizedTagnames/_eventNotifierSources) re-keyed by the full ns-qualified NodeId string so Raw and UNS nodes sharing a bare id stay distinct; _notifierFolders already NodeId-keyed. A historized UNS reference node registers the SAME historian tagname as its backing raw node (both NodeIds -> one tagname). Inbound-write hook routes the full ns-qualified NodeId to the write gateway (realm-aware) and reverts by bare id + realm. HistoryRead seams resolve via NodeId directly. DefaultNamespaceUri kept as a transitional alias to UnsNamespaceUri for the SubscriptionSurvivalTests. - Test doubles across Commons.Tests / OpcUaServer.Tests / Runtime.Tests updated to the new interface signatures; SdkAddressSpaceSinkTests asserts the UNS namespace index for default-realm nodes. Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors. Tests: Commons.Tests 310/310; OpcUaServer.Tests 335 passed / 4 pre-existing skips. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../OpcUa/DeferredAddressSpaceSink.cs | 42 +-- .../OpcUa/IOpcUaAddressSpaceSink.cs | 43 ++- .../OpcUa/ISurgicalAddressSpaceSink.cs | 21 +- .../OtOpcUaNodeManager.cs | 291 ++++++++++++------ .../SdkAddressSpaceSink.cs | 42 +-- .../OpcUa/DeferredAddressSpaceSinkTests.cs | 34 +- .../DeferredSinkForwardingReflectionTests.cs | 18 ++ .../AddressSpaceApplierFailureSurfaceTests.cs | 12 +- .../AddressSpaceApplierHierarchyTests.cs | 12 +- .../AddressSpaceApplierTests.cs | 46 +-- .../DeferredAddressSpaceSinkTests.cs | 34 +- .../SdkAddressSpaceSinkTests.cs | 7 +- .../DiscoveryInjectionEndToEndTests.cs | 12 +- .../OtOpcUaTelemetryHookTests.cs | 12 +- .../OpcUaPublishActorApplyFailureTests.cs | 24 +- .../OpcUa/OpcUaPublishActorRebuildTests.cs | 22 +- .../OpcUa/OpcUaPublishActorTests.cs | 12 +- 17 files changed, 413 insertions(+), 271 deletions(-) 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 f70d8be2..d4e0e39f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs @@ -23,30 +23,30 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical _inner = sink ?? NullOpcUaAddressSpaceSink.Instance; /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) - => _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc); + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm); /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) - => _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc); + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm); /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) - => _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative, realm); /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) - => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) - => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm); /// public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); /// - public void RaiseNodesAddedModelChange(string affectedNodeId) => _inner.RaiseNodesAddedModelChange(affectedNodeId); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm); /// // Forwards to the inner sink when it supports the surgical capability; returns false otherwise — @@ -55,9 +55,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical // Without this forward the surgical optimization is inert on every driver-role host, because // actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape // ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host. - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _inner is ISurgicalAddressSpaceSink surgical - && surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength); + && surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm); /// // Forwards to the inner sink when it supports the surgical capability; returns false otherwise — @@ -65,9 +65,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical // sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild. // Without this forward the rename-refresh optimization is inert on every driver-role host, because // actors inject THIS wrapper, not the inner sink. - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _inner is ISurgicalAddressSpaceSink surgical - && surgical.UpdateFolderDisplayName(folderNodeId, displayName); + && surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm); // R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes / // UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false @@ -75,14 +75,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical // (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is // inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch. /// - public bool RemoveVariableNode(string variableNodeId) - => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId); + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm); /// - public bool RemoveAlarmConditionNode(string alarmNodeId) - => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId); + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm); /// - public bool RemoveEquipmentSubtree(string equipmentNodeId) - => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId); + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId, 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 87c9cac7..cfc6800c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs @@ -13,7 +13,12 @@ public interface IOpcUaAddressSpaceSink /// The value to write. /// The quality status of the value. /// The source timestamp in UTC. - void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc); + /// Which of the two v3 namespaces ( device tree or + /// equipment tree) the lives in — the sink + /// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out + /// calls this once per NodeId (raw + every referencing UNS node) with the matching realm. + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// Write an alarm-condition's full Part 9 state. When a real condition node has been /// materialised for via , @@ -25,7 +30,10 @@ public interface IOpcUaAddressSpaceSink /// The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions). /// The full condition state to project onto the node. /// The source timestamp in UTC. - void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc); + /// The namespace realm lives in (must match the realm + /// the condition was materialised under). + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// /// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients @@ -41,7 +49,10 @@ public interface IOpcUaAddressSpaceSink /// When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when /// false (default) it is a scripted alarm. The node manager tracks native condition node ids so a later /// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine. - void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false); + /// The namespace realm the condition (and its parent folder ) + /// live in. + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// /// Ensure a folder node exists under the given parent. Used by AddressSpaceApplier to @@ -52,7 +63,9 @@ public interface IOpcUaAddressSpaceSink /// The OPC UA node ID for the folder. /// The parent folder node ID, or null for namespace root. /// The display name for the folder. - void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName); + /// The namespace realm the folder (and its ) live in. + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// /// Ensure a Variable node exists at , parented under @@ -77,7 +90,11 @@ public interface IOpcUaAddressSpaceSink /// rank + dimensions carry the array-ness. /// The declared length of the 1-D array when is /// true; ignored for scalars. Null ⇒ length 0 (unbounded). - void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null); + /// The namespace realm the variable (and its ) live + /// in. A historized UNS reference node registers the SAME as its backing + /// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm. + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// /// Tear down + repopulate the address space. Called by OpcUaPublishActor after a @@ -91,7 +108,9 @@ public interface IOpcUaAddressSpaceSink /// (Part 3 GeneralModelChangeEvent, verb NodeAdded). /// /// The node under which discovered nodes were added. - void RaiseNodesAddedModelChange(string affectedNodeId); + /// The namespace realm lives in. + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); } /// OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained @@ -106,23 +125,23 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink private NullOpcUaAddressSpaceSink() { } /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// public void RebuildAddressSpace() { } /// - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs index f4033030..656fcb78 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs @@ -21,8 +21,11 @@ public interface ISurgicalAddressSpaceSink /// The OPC UA built-in data type name (e.g. "Boolean", "Int32", "Float"). /// When true the node is a 1-D array (ValueRank=OneDimension); when false scalar. /// The declared length of the 1-D array when is true; ignored for scalars. + /// The namespace realm lives in — the sink resolves the + /// namespace index from the realm rather than parsing it out of the id string. /// True when the in-place update was applied; false when the node is missing (caller rebuilds). - bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength); + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// Update an existing folder node's DisplayName IN PLACE, notifying subscribers /// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line @@ -32,8 +35,10 @@ public interface ISurgicalAddressSpaceSink /// should rebuild instead). /// The node id of the folder whose display name to update in place. /// The new display name to apply. + /// The namespace realm lives in. /// True when the in-place update was applied; false when the folder is missing (caller rebuilds). - bool UpdateFolderDisplayName(string folderNodeId, string displayName); + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the /// predefined-node set, drop any historized-tagname registration), then raise a Part 3 @@ -43,8 +48,10 @@ public interface ISurgicalAddressSpaceSink /// re-subscription attempts get BadNodeIdUnknown from the SDK. Returns false when the node id is unknown /// (the node-manager maps drifted from the plan — caller falls back to a full rebuild). /// The folder-scoped node id of the variable to remove in place. + /// The namespace realm lives in. /// True when the node was removed; false when the id is unknown (caller rebuilds). - bool RemoveVariableNode(string variableNodeId); + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the /// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The @@ -52,8 +59,10 @@ public interface ISurgicalAddressSpaceSink /// stops replaying on ConditionRefresh. Returns false when the condition id is unknown (caller /// rebuilds). /// The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id). + /// The namespace realm lives in. /// True when the condition was removed; false when the id is unknown (caller rebuilds). - bool RemoveAlarmConditionNode(string alarmNodeId); + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); /// Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables, /// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames, @@ -62,6 +71,8 @@ public interface ISurgicalAddressSpaceSink /// the equipment folder outside the lock. Returns false when the folder id is unknown (caller /// rebuilds). /// The equipment folder node id whose entire subtree to remove. + /// The namespace realm lives in. /// True when the subtree was removed; false when the id is unknown (caller rebuilds). - bool RemoveEquipmentSubtree(string equipmentNodeId); + // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. + bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 335840d8..b6375a5e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -36,7 +36,20 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// public sealed class OtOpcUaNodeManager : CustomNodeManager2 { - public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns"; + /// v3 dual-namespace: both the Raw (device tree) and UNS (equipment tree) namespaces are + /// registered with the SDK so nodes can be minted in either. is + /// registered FIRST (namespace index [0]) and + /// SECOND ([1]); maps a realm + /// to its index. Which realm a node belongs to travels with each sink call as an + /// — the namespace is never parsed back out of the id string. + private static readonly string[] RegisteredNamespaceUris = { V3NodeIds.RawNamespaceUri, V3NodeIds.UnsNamespaceUri }; + + /// Transitional alias kept so the pre-v3 single-namespace integration tests + /// (SubscriptionSurvivalTests) still resolve a namespace index. It points at + /// — the same realm as the transitional default of every sink + /// method — so those tests keep resolving the namespace the applier's default-realm nodes live in. The two + /// v3 URIs are the real namespaces; this alias is removed once those tests target Raw/UNS explicitly. + public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri; private readonly ConcurrentDictionary _variables = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _folders = new(StringComparer.Ordinal); @@ -73,11 +86,47 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The OPC UA server instance. /// The application configuration. public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration) - : base(server, configuration, DefaultNamespaceUri) + : base(server, configuration, RegisteredNamespaceUris) { - // SystemContext is initialised by the base ctor. + // SystemContext is initialised by the base ctor; both namespace URIs are now registered and their + // indexes captured (in the order passed) in NamespaceIndexes. } + /// Resolve a realm to the SDK namespace index its nodes are minted under. Raw is registered + /// first ([0]), UNS second ([1]) — see . + /// The address-space realm. + /// The namespace index for the realm. + internal ushort NamespaceIndexForRealm(AddressSpaceRealm realm) => realm switch + { + AddressSpaceRealm.Raw => NamespaceIndexes[0], + AddressSpaceRealm.Uns => NamespaceIndexes[1], + _ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."), + }; + + /// Recover the realm of an already-resolved SDK from its namespace index — + /// used at the SDK-facing seams (inbound write, HistoryRead) where the request carries a full NodeId. + /// Any index that is not the Raw namespace is treated as UNS (the two registered indexes are the only + /// ones this manager mints into). + /// The resolved SDK node id. + /// The realm the node belongs to. + private AddressSpaceRealm RealmOf(NodeId nodeId) => + nodeId.NamespaceIndex == NamespaceIndexes[0] ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns; + + /// Build the internal map key for a realm-qualified string id. The key is the node's full + /// namespace-qualified NodeId string (e.g. ns=2;s=<id>) so a Raw node and a UNS node that + /// share the same s= identifier are distinct keys — a bare id is no longer globally unique across + /// the two namespaces. Matches for the same node. + /// The realm the id lives in. + /// The bare s= identifier. + /// The namespace-qualified map key. + private string MapKey(AddressSpaceRealm realm, string id) => new NodeId(id, NamespaceIndexForRealm(realm)).ToString(); + + /// Build the internal map key for an already-resolved SDK (the SDK-facing + /// seams). Equal to for the same node. + /// The resolved SDK node id. + /// The namespace-qualified map key. + private static string MapKey(NodeId nodeId) => nodeId.ToString(); + /// Gets the count of variable nodes currently managed. public int VariableCount => _variables.Count; /// Gets the count of folder nodes currently managed. @@ -246,8 +295,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics. /// The alarm node identifier (== ScriptedAlarmId). /// The cached , or null when none is registered. - public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId) => - _alarmConditions.TryGetValue(alarmNodeId, out var condition) ? condition : null; + public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + _alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var condition) ? condition : null; /// Phase C: look up the resolved historian tagname registered for a historized variable /// node, or null when the node is not historized. The (later) HistoryRead override resolves an @@ -255,9 +304,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The variable node identifier. /// The resolved historian tagname when historized; otherwise null. /// True when the node is registered as historized; otherwise false. - public bool TryGetHistorizedTagname(string nodeId, out string? tagname) + public bool TryGetHistorizedTagname(string nodeId, out string? tagname, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { - if (_historizedTagnames.TryGetValue(nodeId, out var t)) { tagname = t; return true; } + if (_historizedTagnames.TryGetValue(MapKey(realm, nodeId), out var t)) { tagname = t; return true; } + tagname = null; + return false; + } + + /// NodeId-typed overload used by the HistoryRead override, which holds a request's full + /// (namespace-qualified) NodeId — resolves the historian tagname without recovering the realm first. + /// A historized UNS reference node and its backing raw node register the SAME tagname under distinct + /// NodeIds, so a read against either resolves the one series. + /// The resolved SDK node id from the HistoryRead request. + /// The resolved historian tagname when historized; otherwise null. + /// True when the node is registered as historized; otherwise false. + private bool TryGetHistorizedTagname(NodeId nodeId, out string? tagname) + { + if (_historizedTagnames.TryGetValue(MapKey(nodeId), out var t)) { tagname = t; return true; } tagname = null; return false; } @@ -266,16 +329,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Exposed for tests so they can assert the SDK node's Historizing / AccessLevel attributes. /// The variable node identifier. /// The cached , or null when none is registered. - internal BaseDataVariableState? TryGetVariable(string nodeId) => - _variables.TryGetValue(nodeId, out var variable) ? variable : null; + internal BaseDataVariableState? TryGetVariable(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + _variables.TryGetValue(MapKey(realm, nodeId), out var variable) ? variable : null; /// Look up a materialised folder node by its NodeId string, or null if not present. /// Exposed for tests so they can resolve an equipment folder's NodeId (e.g. the event-notifier /// node a HistoryReadEvents request targets). /// The folder node identifier. /// The cached , or null when none is registered. - internal FolderState? TryGetFolder(string nodeId) => - _folders.TryGetValue(nodeId, out var folder) ? folder : null; + internal FolderState? TryGetFolder(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + _folders.TryGetValue(MapKey(realm, nodeId), out var folder) ? folder : null; /// /// Apply a value write from . Creates the @@ -286,19 +349,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The new value to write. /// The OPC UA quality status code. /// The timestamp of the value in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(nodeId); EnsureAddressSpaceCreated(); + var ns = NamespaceIndexForRealm(realm); + var key = MapKey(realm, nodeId); lock (Lock) { // CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode), // so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too. - if (!_variables.TryGetValue(nodeId, out var variable)) + if (!_variables.TryGetValue(key, out var variable)) { - variable = CreateVariable(nodeId); - _variables[nodeId] = variable; + variable = CreateVariable(nodeId, ns); + _variables[key] = variable; } variable.Value = value; @@ -321,17 +386,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node identifier of the alarm (== ScriptedAlarmId for materialised conditions). /// The full condition state to project onto the node. /// The timestamp of the alarm state change in UTC. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentNullException.ThrowIfNull(state); EnsureAddressSpaceCreated(); + var ns = NamespaceIndexForRealm(realm); + var key = MapKey(realm, alarmNodeId); // Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear // _alarmConditions / detach the condition node between the lookup and the Set* calls. lock (Lock) { - if (_alarmConditions.TryGetValue(alarmNodeId, out var condition)) + if (_alarmConditions.TryGetValue(key, out var condition)) { // T20 delta-gate: read the node's CURRENT live condition state FIRST (before projecting // the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming @@ -396,10 +463,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so // un-materialised callers (and the existing unit tests) keep working. CreateVariable mutates // the SDK address space, so it MUST run under Lock (see WriteValue). - if (!_variables.TryGetValue(alarmNodeId, out var variable)) + if (!_variables.TryGetValue(key, out var variable)) { - variable = CreateVariable(alarmNodeId); - _variables[alarmNodeId] = variable; + variable = CreateVariable(alarmNodeId, ns); + _variables[key] = variable; } variable.Value = new[] { state.Active, state.Acknowledged }; @@ -611,12 +678,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// . LimitAlarm deliberately falls back to base per the T13 /// notes — a script alarm carries no High/Low limits to populate. /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); EnsureAddressSpaceCreated(); + var ns = NamespaceIndexForRealm(realm); + var conditionKey = MapKey(realm, alarmNodeId); lock (Lock) { // R2-07 T4a: idempotent skip-if-present-and-same-kind. On a PURE-ADD apply, @@ -627,12 +696,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // re-severity/type change arrives as a ChangedAlarms delta ⇒ full rebuild (the map is cleared // first), so it never reaches this path with a stale-but-present entry; the drop-and-recreate // stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag. - if (_alarmConditions.ContainsKey(alarmNodeId) && _nativeAlarmNodeIds.Contains(alarmNodeId) == isNative) + if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative) return; // Idempotent: drop any prior node for this id so a re-materialise (e.g. changed // type/severity on redeploy) reflects cleanly instead of leaking the old node. - if (_alarmConditions.TryRemove(alarmNodeId, out var existing)) + if (_alarmConditions.TryRemove(conditionKey, out var existing)) { existing.Parent?.RemoveChild(existing); PredefinedNodes?.Remove(existing.NodeId); @@ -640,9 +709,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new // kind, so always drop the stale native flag first and only re-add it below when isNative. - _nativeAlarmNodeIds.Remove(alarmNodeId); + _nativeAlarmNodeIds.Remove(conditionKey); - var parent = ResolveParentFolder(equipmentNodeId); + var parent = ResolveParentFolder(equipmentNodeId, realm); AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent); alarm.SymbolicName = displayName; @@ -654,8 +723,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // type's embedded definition; we do not hand-build them. alarm.Create( SystemContext, - new NodeId(alarmNodeId, NamespaceIndex), - new QualifiedName(displayName, NamespaceIndex), + new NodeId(alarmNodeId, ns), + new QualifiedName(displayName, ns), new LocalizedText(displayName), assignNodeIds: true); @@ -688,7 +757,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. Confirm/AddComment/ // Shelve stay on the scripted path even for native conditions (H6c scope is Acknowledge only). alarm.OnAcknowledge = (context, condition, _, comment) => - IsNativeAlarmNode(alarmNodeId) + IsNativeAlarmNode(alarmNodeId, realm) ? HandleNativeAlarmAck(context, condition, comment) : HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null); alarm.OnConfirm = (context, condition, _, comment) => @@ -736,7 +805,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // engine enable/disable surface — they short-circuit to BadNotSupported. alarm.OnEnableDisable = (context, condition, enabling) => { - if (IsNativeAlarmNode(alarmNodeId)) + if (IsNativeAlarmNode(alarmNodeId, realm)) return new ServiceResult(StatusCodes.BadNotSupported); return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null); }; @@ -749,10 +818,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 EnsureFolderIsEventNotifier(parent); AddPredefinedNode(SystemContext, alarm); - _alarmConditions[alarmNodeId] = alarm; + _alarmConditions[conditionKey] = alarm; // H6a: record native (driver-fed) conditions so a later task can route their inbound // Acknowledge to the driver rather than the scripted engine. - if (isNative) _nativeAlarmNodeIds.Add(alarmNodeId); + if (isNative) _nativeAlarmNodeIds.Add(conditionKey); } } @@ -761,11 +830,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// inbound Acknowledge to the driver instead of the scripted engine. /// The alarm condition node id. /// True when the condition is native (driver-fed); false when it is scripted or not found. - internal bool IsNativeAlarmNode(string alarmNodeId) + internal bool IsNativeAlarmNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { // _nativeAlarmNodeIds is a plain HashSet mutated only under Lock (in MaterialiseAlarmCondition / // RebuildAddressSpace), so guard the read with the same Lock rather than risk a torn concurrent read. - lock (Lock) return _nativeAlarmNodeIds.Contains(alarmNodeId); + lock (Lock) return _nativeAlarmNodeIds.Contains(MapKey(realm, alarmNodeId)); } /// @@ -906,7 +975,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write // (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`). var optimisticValue = value; - var nodeKey = node.NodeId.Identifier?.ToString() ?? string.Empty; + // v3 dual-namespace: route by the FULL (namespace-qualified) NodeId string so a Raw write and a UNS + // write to nodes that share a bare id are distinguishable downstream — WP3's write gateway keys its + // (DriverInstanceId, RawPath) resolution on this ns-qualified id. Revert keys off the bare id + realm. + var nodeKey = node.NodeId.ToString(); + var nodeRealm = RealmOf(node.NodeId); + var nodeBareId = node.NodeId.Identifier?.ToString() ?? string.Empty; object? priorValue = null; StatusCode priorStatus = StatusCodes.Good; if (node is BaseDataVariableState variable) @@ -931,7 +1005,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 t => { var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted"); - RevertOptimisticWriteIfNeeded(nodeKey, outcome, optimisticValue, priorValue, priorStatus, clientUserId); + RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm); }, CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default); @@ -1132,14 +1206,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// from to populate the audit event's ClientUserId; null when unknown. internal void RevertOptimisticWriteIfNeeded( string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus, - string? clientUserId) + string? clientUserId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { // Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released. AuditWriteUpdateEventState? auditEvent = null; + var key = MapKey(realm, nodeId); lock (Lock) { - if (!_variables.TryGetValue(nodeId, out var variable)) return; // rebuilt/removed ⇒ no-op + if (!_variables.TryGetValue(key, out var variable)) return; // rebuilt/removed ⇒ no-op if (!ShouldRevert(outcome, variable.Value, optimisticValue)) return; // success, or poll moved it on // Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore @@ -1292,8 +1367,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // A historian is wired: advertise event history on this notifier and register it as a source. // The equipment-folder NodeId identifier IS the equipment id IS the ReadEventsAsync sourceName. folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead); + // Key by the full (namespace-qualified) NodeId string so a Raw and a UNS folder that share the + // same bare id are distinct sources; the VALUE stays the bare id — that is the historian + // ReadEventsAsync sourceName (== the equipment id). var sourceName = folder.NodeId.Identifier?.ToString() ?? string.Empty; - _eventNotifierSources[sourceName] = sourceName; + _eventNotifierSources[MapKey(folder.NodeId)] = sourceName; } AddRootNotifier(folder); folder.ClearChangeMasks(SystemContext, includeChildren: false); @@ -1320,23 +1398,25 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node identifier of the folder. /// The node identifier of the parent folder; null to use the namespace root. /// The display name of the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(folderNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); EnsureAddressSpaceCreated(); - if (_folders.ContainsKey(folderNodeId)) return; + var ns = NamespaceIndexForRealm(realm); + var key = MapKey(realm, folderNodeId); + if (_folders.ContainsKey(key)) return; lock (Lock) { - if (_folders.ContainsKey(folderNodeId)) return; + if (_folders.ContainsKey(key)) return; - var parent = ResolveParentFolder(parentNodeId); + var parent = ResolveParentFolder(parentNodeId, realm); var folder = new FolderState(parent) { - NodeId = new NodeId(folderNodeId, NamespaceIndex), - BrowseName = new QualifiedName(folderNodeId, NamespaceIndex), + NodeId = new NodeId(folderNodeId, ns), + BrowseName = new QualifiedName(folderNodeId, ns), DisplayName = displayName, EventNotifier = EventNotifiers.None, TypeDefinitionId = ObjectTypeIds.FolderType, @@ -1344,7 +1424,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 }; parent.AddChild(folder); AddPredefinedNode(SystemContext, folder); - _folders[folderNodeId] = folder; + _folders[key] = folder; } } @@ -1363,13 +1443,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node identifier of the folder to update in place. /// The new display name to apply. /// True when the in-place update was applied; false when the folder id is unknown. - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(folderNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); lock (Lock) { - if (!_folders.TryGetValue(folderNodeId, out var folder)) return false; + if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return false; folder.DisplayName = displayName; folder.ClearChangeMasks(SystemContext, includeChildren: false); return true; @@ -1405,20 +1485,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// — rank + dimensions carry the array-ness. /// Phase 4c: the declared length of the 1-D array when /// is true; ignored for scalars. Null ⇒ length 0. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); EnsureAddressSpaceCreated(); + var ns = NamespaceIndexForRealm(realm); + var key = MapKey(realm, variableNodeId); // If already present, leave it alone (idempotent re-applies). - if (_variables.ContainsKey(variableNodeId)) return; + if (_variables.ContainsKey(key)) return; lock (Lock) { - if (_variables.ContainsKey(variableNodeId)) return; + if (_variables.ContainsKey(key)) return; - var parent = ResolveParentFolder(parentFolderNodeId); + var parent = ResolveParentFolder(parentFolderNodeId, realm); // Phase C: a non-null historian tagname makes the node Historizing and grants the HistoryRead // access bit (on top of the writable composite) so clients can browse + HistoryRead it. var historized = historianTagname is not null; @@ -1427,8 +1509,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var access = ComposeAccessLevel(writable, historized); var variable = new BaseDataVariableState(parent) { - NodeId = new NodeId(variableNodeId, NamespaceIndex), - BrowseName = new QualifiedName(variableNodeId, NamespaceIndex), + NodeId = new NodeId(variableNodeId, ns), + BrowseName = new QualifiedName(variableNodeId, ns), DisplayName = displayName, TypeDefinitionId = VariableTypeIds.BaseDataVariableType, ReferenceTypeId = ReferenceTypeIds.Organizes, @@ -1452,10 +1534,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } parent.AddChild(variable); AddPredefinedNode(SystemContext, variable); - _variables[variableNodeId] = variable; + _variables[key] = variable; // Phase C: register the resolved historian tagname so the HistoryRead override can map this - // NodeId back to its Aveva/historian source. - if (historized) _historizedTagnames[variableNodeId] = historianTagname!; + // NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME + // tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname. + if (historized) _historizedTagnames[key] = historianTagname!; } } @@ -1500,22 +1583,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar. /// The declared length of the 1-D array when is true; ignored for scalars. /// True when the in-place update was applied; false when the node id is unknown. - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(dataType); // widened surface ⇒ explicit contract (unknown names still map to BaseDataType) + var key = MapKey(realm, variableNodeId); BaseDataVariableState? shapeChangedNode = null; lock (Lock) { - if (!_variables.TryGetValue(variableNodeId, out var v)) return false; + if (!_variables.TryGetValue(key, out var v)) return false; var historized = historianTagname is not null; var access = ComposeAccessLevel(writable, historized); v.AccessLevel = access; v.UserAccessLevel = access; v.Historizing = historized; v.OnWriteValue = writable ? OnEquipmentTagWrite : null; - if (historized) _historizedTagnames[variableNodeId] = historianTagname!; - else _historizedTagnames.TryRemove(variableNodeId, out _); + if (historized) _historizedTagnames[key] = historianTagname!; + else _historizedTagnames.TryRemove(key, out _); // Swap DataType + ValueRank + ArrayDimensions in place, but only when they actually differ // from the live node — a Writable/Historizing-only change leaves the shape untouched (no value @@ -1628,13 +1712,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// /// /// The folder-scoped node id of the parent under which nodes were added. - public void RaiseNodesAddedModelChange(string affectedNodeId) + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(affectedNodeId); GeneralModelChangeEventState e; lock (Lock) { - e = BuildNodesAddedModelChange(affectedNodeId); + e = BuildNodesAddedModelChange(affectedNodeId, realm); } // Report OUTSIDE Lock — Server.ReportEvent re-enters the server's own subscription/event path; holding // Lock across it risks a lock-order inversion (mirrors ReportNodeShapeChangedEvent). @@ -1663,9 +1747,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// monitored-item to observe). /// The folder-scoped node id of the parent under which nodes were added. /// A populated, unreported . - internal GeneralModelChangeEventState BuildNodesAddedModelChange(string affectedNodeId) + internal GeneralModelChangeEventState BuildNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { - var affected = new NodeId(affectedNodeId, NamespaceIndex); + var affected = new NodeId(affectedNodeId, NamespaceIndexForRealm(realm)); var e = new GeneralModelChangeEventState(null); e.Initialize( SystemContext, @@ -1683,7 +1767,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // The affected node is the parent the children were added under; carry its TypeDefinition (a Folder // for an equipment parent) just as the shape-changed builder carries the variable's. Null when the // id is unknown — a valid Part 3 "type not applicable", and clients re-browse Affected regardless. - AffectedType = ResolveAffectedTypeDefinition(affectedNodeId), + AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm), Verb = (byte)ModelChangeStructureVerbMask.NodeAdded, }; // SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder @@ -1700,9 +1784,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// can assert the populated Changes structure at the nearest deterministic seam. /// The node id of the deleted node. /// A populated, unreported . - internal GeneralModelChangeEventState BuildNodesRemovedModelChange(string affectedNodeId) + internal GeneralModelChangeEventState BuildNodesRemovedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { - var affected = new NodeId(affectedNodeId, NamespaceIndex); + var affected = new NodeId(affectedNodeId, NamespaceIndexForRealm(realm)); var e = new GeneralModelChangeEventState(null); e.Initialize( SystemContext, @@ -1717,7 +1801,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { Affected = affected, // The node is already gone from the maps, so its TypeDefinition is not applicable (Null). - AffectedType = ResolveAffectedTypeDefinition(affectedNodeId), + AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm), Verb = (byte)ModelChangeStructureVerbMask.NodeDeleted, }; e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false); @@ -1748,10 +1832,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Resolve the TypeDefinition of a materialised node id from the live folder/variable maps for a /// model-change event's AffectedType; when the id is not registered. /// The folder-scoped node id whose TypeDefinition is wanted. - private NodeId ResolveAffectedTypeDefinition(string nodeId) + private NodeId ResolveAffectedTypeDefinition(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { - if (_folders.TryGetValue(nodeId, out var folder)) return folder.TypeDefinitionId; - if (_variables.TryGetValue(nodeId, out var variable)) return variable.TypeDefinitionId; + var key = MapKey(realm, nodeId); + if (_folders.TryGetValue(key, out var folder)) return folder.TypeDefinitionId; + if (_variables.TryGetValue(key, out var variable)) return variable.TypeDefinitionId; return NodeId.Null; } @@ -1828,34 +1913,36 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// - public bool RemoveVariableNode(string variableNodeId) + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); EnsureAddressSpaceCreated(); + var key = MapKey(realm, variableNodeId); GeneralModelChangeEventState e; lock (Lock) { // Unknown id ⇒ the node-manager maps drifted from what the planner believes; return false so the // caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node // teardown inside RebuildAddressSpace, scoped to this one id. - if (!_variables.TryRemove(variableNodeId, out var variable)) return false; + if (!_variables.TryRemove(key, out var variable)) return false; variable.Parent?.RemoveChild(variable); PredefinedNodes?.Remove(variable.NodeId); // Drop the historized-tagname registration alongside the variable it maps (Phase C parity). - _historizedTagnames.TryRemove(variableNodeId, out _); - e = BuildNodesRemovedModelChange(variableNodeId); + _historizedTagnames.TryRemove(key, out _); + e = BuildNodesRemovedModelChange(variableNodeId, realm); } ReportModelChangeOutsideLock(e, variableNodeId); return true; } /// - public bool RemoveAlarmConditionNode(string alarmNodeId) + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); EnsureAddressSpaceCreated(); + var key = MapKey(realm, alarmNodeId); GeneralModelChangeEventState e; lock (Lock) { @@ -1864,33 +1951,36 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // promotion is intentionally NOT demoted here — other conditions may still hang under it, and a // lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole // equipment goes). - if (!_alarmConditions.TryRemove(alarmNodeId, out var condition)) return false; + if (!_alarmConditions.TryRemove(key, out var condition)) return false; condition.Parent?.RemoveChild(condition); PredefinedNodes?.Remove(condition.NodeId); - _nativeAlarmNodeIds.Remove(alarmNodeId); - e = BuildNodesRemovedModelChange(alarmNodeId); + _nativeAlarmNodeIds.Remove(key); + e = BuildNodesRemovedModelChange(alarmNodeId, realm); } ReportModelChangeOutsideLock(e, alarmNodeId); return true; } /// - public bool RemoveEquipmentSubtree(string equipmentNodeId) + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId); EnsureAddressSpaceCreated(); + var equipKey = MapKey(realm, equipmentNodeId); GeneralModelChangeEventState e; lock (Lock) { // Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds). - if (!_folders.ContainsKey(equipmentNodeId)) return false; + if (!_folders.ContainsKey(equipKey)) return false; - // Folder-scoped NodeIds mean every descendant's id is the equipment id itself or begins with - // "/" (sub-folders, variables, condition nodes). Snapshot each map's in-scope keys - // first (ToList) so we don't mutate while enumerating. - var prefix = equipmentNodeId + "/"; - bool InScope(string id) => id == equipmentNodeId || id.StartsWith(prefix, StringComparison.Ordinal); + // Maps key on the full namespace-qualified NodeId string (e.g. "ns=3;s="). Every + // descendant in the SAME realm shares that key exactly or begins with "/" (sub-folders, + // variables, condition nodes); the "ns=N;s=" prefix makes the match realm-scoped for free, so a + // node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot each + // map's in-scope keys first (ToList) so we don't mutate while enumerating. + var prefix = equipKey + "/"; + bool InScope(string key) => key == equipKey || key.StartsWith(prefix, StringComparison.Ordinal); // Value variables (+ their historized-tagname registrations). foreach (var id in _variables.Keys.Where(InScope).ToList()) @@ -1917,8 +2007,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // 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. - // _notifierFolders is keyed by the folder's NodeId; match on its string identifier. - foreach (var kvp in _notifierFolders.Where(kv => InScope(kv.Key.Identifier?.ToString() ?? string.Empty)).ToList()) + // _notifierFolders is keyed by the folder's NodeId; match InScope on its full (ns-qualified) string. + foreach (var kvp in _notifierFolders.Where(kv => InScope(kv.Key.ToString())).ToList()) { RemoveRootNotifier(kvp.Value); _notifierFolders.Remove(kvp.Key); @@ -1937,17 +2027,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } - e = BuildNodesRemovedModelChange(equipmentNodeId); + e = BuildNodesRemovedModelChange(equipmentNodeId, realm); } ReportModelChangeOutsideLock(e, equipmentNodeId); return true; } - private FolderState ResolveParentFolder(string? parentNodeId) + private FolderState ResolveParentFolder(string? parentNodeId, AddressSpaceRealm realm) { EnsureAddressSpaceCreated(); if (string.IsNullOrEmpty(parentNodeId)) return _root!; - return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!; + // A node's parent lives in the SAME realm (a raw tag's group is raw; a UNS reference's equipment + // folder is UNS), so resolve the parent key in that realm; unknown parent ⇒ hang under the shared root. + return _folders.TryGetValue(MapKey(realm, parentNodeId), out var existing) ? existing : _root!; } /// Guard the address-space mutators against a too-early call. _root @@ -2124,8 +2216,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var work = new List>(nodesToProcess.Count); foreach (var handle in nodesToProcess) { - var idString = handle.NodeId.Identifier?.ToString(); - if (idString is null || !_eventNotifierSources.TryGetValue(idString, out var sourceName)) + // Key on the full (namespace-qualified) NodeId so a Raw and a UNS notifier folder that share a + // bare id resolve to their own source registration. + if (!_eventNotifierSources.TryGetValue(MapKey(handle.NodeId), out var sourceName)) { // Not a registered event-history source (plain folder / Null-source promotion) ⇒ unsupported. // Set both errors and results explicitly on every bad path — don't rely on the SDK base @@ -2438,8 +2531,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 Func> read, CancellationToken ct) { - var idString = handle.NodeId.Identifier?.ToString(); - if (idString is null || !TryGetHistorizedTagname(idString, out var tagname)) + if (!TryGetHistorizedTagname(handle.NodeId, out var tagname)) { // Not a historized node we own a tagname for — unsupported. (The base pre-seeds this same // status, but set it explicitly so the contract is local + obvious.) @@ -2557,8 +2649,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 else { // Fresh read: resolve the node's historian tagname (as ServeNode does). - var idString = handle.NodeId.Identifier?.ToString(); - if (idString is null || !TryGetHistorizedTagname(idString, out var resolved) || resolved is null) + if (!TryGetHistorizedTagname(handle.NodeId, out var resolved) || resolved is null) { errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; return; @@ -2800,12 +2891,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } - private BaseDataVariableState CreateVariable(string nodeId) + private BaseDataVariableState CreateVariable(string nodeId, ushort ns) { var v = new BaseDataVariableState(_root) { - NodeId = new NodeId(nodeId, NamespaceIndex), - BrowseName = new QualifiedName(nodeId, NamespaceIndex), + NodeId = new NodeId(nodeId, ns), + BrowseName = new QualifiedName(nodeId, ns), DisplayName = nodeId, TypeDefinitionId = VariableTypeIds.BaseDataVariableType, ReferenceTypeId = ReferenceTypeIds.Organizes, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index 16350b02..65305767 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -21,48 +21,48 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre } /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) - => _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc); + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm); /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) - => _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc); + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm); /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) - => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative, realm); /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) - => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) - => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm); /// - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) - => _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength); + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm); /// - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) - => _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName); + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName, realm); /// - public bool RemoveVariableNode(string variableNodeId) - => _nodeManager.RemoveVariableNode(variableNodeId); + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.RemoveVariableNode(variableNodeId, realm); /// - public bool RemoveAlarmConditionNode(string alarmNodeId) - => _nodeManager.RemoveAlarmConditionNode(alarmNodeId); + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.RemoveAlarmConditionNode(alarmNodeId, realm); /// - public bool RemoveEquipmentSubtree(string equipmentNodeId) - => _nodeManager.RemoveEquipmentSubtree(equipmentNodeId); + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + => _nodeManager.RemoveEquipmentSubtree(equipmentNodeId, realm); /// public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace(); /// - public void RaiseNodesAddedModelChange(string affectedNodeId) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, 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 6b2e2ae8..33082333 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 @@ -186,15 +186,15 @@ public class DeferredAddressSpaceSinkTests public bool WriteValueCalled { get; private set; } public bool RebuildCalled { get; private set; } - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => WriteValueCalled = true; - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RebuildAddressSpace() => RebuildCalled = true; - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } private sealed class SpySurgicalSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink @@ -202,21 +202,21 @@ public class DeferredAddressSpaceSinkTests public bool UpdateCalled { get; private set; } public bool FolderRenameCalled { get; private set; } - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RebuildAddressSpace() { } - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { UpdateCalled = true; return true; } - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { FolderRenameCalled = true; return true; @@ -226,8 +226,8 @@ public class DeferredAddressSpaceSinkTests public bool RemoveAlarmCalled { get; private set; } public bool RemoveSubtreeCalled { get; private set; } - public bool RemoveVariableNode(string variableNodeId) { RemoveVariableCalled = true; return true; } - public bool RemoveAlarmConditionNode(string alarmNodeId) { RemoveAlarmCalled = true; return true; } - public bool RemoveEquipmentSubtree(string equipmentNodeId) { RemoveSubtreeCalled = true; return true; } + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveVariableCalled = true; return true; } + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveAlarmCalled = true; return true; } + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveSubtreeCalled = true; return true; } } } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs index 7903ac34..5a005ee0 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs @@ -74,6 +74,24 @@ public class DeferredSinkForwardingReflectionTests } } + [Fact] + public void Every_node_naming_sink_method_carries_a_realm_discriminator() + { + // v3 B4-WP2 dual-namespace invariant: a node id alone is no longer globally unique across the two + // namespaces (Raw vs UNS), so every sink method that NAMES a node must carry an AddressSpaceRealm + // discriminator — the sink resolves the namespace from the realm rather than parsing the id string. + // RebuildAddressSpace is the sole node-naming-free method (it clears BOTH realms). This guard locks in + // the realm surface so a future method can't be added that names a node without a realm. + foreach (var method in ForwardingInterfaces.SelectMany(i => i.GetMethods())) + { + if (method.Name == nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)) continue; + + method.GetParameters().Any(p => p.ParameterType == typeof(AddressSpaceRealm)).ShouldBeTrue( + $"{method.DeclaringType!.Name}.{method.Name} names a node but has no AddressSpaceRealm parameter — " + + "a bare node id is ambiguous across the Raw and UNS namespaces (B4-WP2). Add the realm discriminator."); + } + } + [Fact] public void Every_IServiceLevelPublisher_method_reaches_the_inner_publisher() { 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 9582c8d5..6e6d0729 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -161,24 +161,24 @@ public sealed class AddressSpaceApplierFailureSurfaceTests public bool ThrowOnAlarmWrite { get; init; } public bool ThrowOnMaterialiseAlarm { get; init; } - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault"); } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { if (ThrowOnMaterialiseAlarm) throw new InvalidOperationException("simulated MaterialiseAlarmCondition fault"); } - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault"); } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault"); } @@ -188,6 +188,6 @@ public sealed class AddressSpaceApplierFailureSurfaceTests if (ThrowOnRebuild) throw new InvalidOperationException("simulated RebuildAddressSpace fault"); } - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } } 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 14cc8f87..c5210663 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs @@ -307,24 +307,24 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable /// The value to write. /// The OPC UA quality value. /// The source timestamp in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Records an alarm condition write (stub implementation for testing). /// The node ID of the alarm condition. /// The full condition state snapshot. /// The source timestamp in UTC. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Materialises an alarm condition (stub implementation for testing). /// The alarm node ID (== ScriptedAlarmId). /// The equipment folder node ID. /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Records a folder creation request. /// The node ID of the folder. /// The node ID of the parent folder, or null for root. /// The display name of the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _calls.Enqueue((folderNodeId, parentNodeId, displayName)); /// Ensures a variable exists (stub implementation for testing). /// The node ID of the variable. @@ -333,10 +333,10 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Rebuilds the address space (stub implementation for testing). public void RebuildAddressSpace() { } /// Announces a NodeAdded model-change (stub implementation for testing). - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } } 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 97dfefc5..5d6ff679 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -2170,7 +2170,7 @@ public sealed class AddressSpaceApplierTests /// The new OPC UA data type name to apply in place. /// The new array-ness of the node. /// The new 1-D array length when is true. - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { SurgicalQueue.Enqueue((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength)); return SurgicalReturns; @@ -2187,7 +2187,7 @@ public sealed class AddressSpaceApplierTests /// Records a surgical in-place folder display-name update; returns . /// The folder node ID to update in place. /// The new display name to apply. - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { FolderRenameQueue.Enqueue((folderNodeId, displayName)); return FolderRenameReturns; @@ -2202,21 +2202,21 @@ public sealed class AddressSpaceApplierTests public bool RemoveReturns { get; init; } = true; /// Records a surgical variable-node removal; returns . - public bool RemoveVariableNode(string variableNodeId) + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveQueue.Enqueue(("var", variableNodeId)); return RemoveReturns; } /// Records a surgical alarm-condition-node removal; returns . - public bool RemoveAlarmConditionNode(string alarmNodeId) + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveQueue.Enqueue(("alarm", alarmNodeId)); return RemoveReturns; } /// Records a surgical equipment-subtree removal; returns . - public bool RemoveEquipmentSubtree(string equipmentNodeId) + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveQueue.Enqueue(("equipment", equipmentNodeId)); return RemoveReturns; @@ -2262,13 +2262,13 @@ public sealed class AddressSpaceApplierTests /// The value to write. /// The OPC UA quality. /// The source timestamp in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ValueWriteQueue.Enqueue((nodeId, quality)); /// Records an alarm condition write call. /// The alarm node ID. /// The full condition state snapshot. /// The source timestamp in UTC. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => AlarmQueue.Enqueue((alarmNodeId, state)); /// Records an alarm-condition materialise call. /// The alarm node ID (== ScriptedAlarmId). @@ -2276,13 +2276,13 @@ public sealed class AddressSpaceApplierTests /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative)); /// Records a folder creation call. /// The folder node ID. /// The parent folder node ID, if any. /// The display name for the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName)); /// Records a variable creation call. /// The variable node ID. @@ -2291,7 +2291,7 @@ public sealed class AddressSpaceApplierTests /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); HistorianQueue.Enqueue((variableNodeId, historianTagname)); @@ -2306,7 +2306,7 @@ public sealed class AddressSpaceApplierTests public List ModelChangeCalls => ModelChangeQueue.ToList(); /// Records a NodeAdded model-change announcement. /// The node under which discovered nodes were added. - public void RaiseNodesAddedModelChange(string affectedNodeId) => ModelChangeQueue.Enqueue(affectedNodeId); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId); } /// A recording sink that does NOT implement — used to @@ -2318,19 +2318,19 @@ public sealed class AddressSpaceApplierTests public int RebuildCalls; /// Records a value write (no-op in this sink). - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op alarm condition write call. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op alarm-condition materialise call. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op folder creation call. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op variable creation call. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); /// No-op NodeAdded model-change announcement. - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } private sealed class ThrowingSink : IOpcUaAddressSpaceSink @@ -2345,13 +2345,13 @@ public sealed class AddressSpaceApplierTests /// The value to write. /// The OPC UA quality. /// The source timestamp in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Throws an exception if configured to do so. /// The alarm node ID. /// The full condition state snapshot. /// The source timestamp in UTC. /// Thrown when configured to throw on alarm write. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault"); } @@ -2361,12 +2361,12 @@ public sealed class AddressSpaceApplierTests /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op folder creation call. /// The folder node ID. /// The parent folder node ID, if any. /// The display name for the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op variable creation call. /// The variable node ID. /// The parent folder node ID, if any. @@ -2374,10 +2374,10 @@ public sealed class AddressSpaceApplierTests /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op rebuild address space call. public void RebuildAddressSpace() { } /// No-op NodeAdded model-change announcement. - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } } 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 d10a9f58..2265421e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs @@ -228,19 +228,19 @@ public sealed class DeferredAddressSpaceSinkTests public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList(); /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"WV:{nodeId}"); /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"WA:{alarmNodeId}"); /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"MA:{alarmNodeId}"); /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"EF:{folderNodeId}"); /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { CallQueue.Enqueue($"EV:{variableNodeId}"); HistorianQueue.Enqueue((variableNodeId, historianTagname)); @@ -248,7 +248,7 @@ public sealed class DeferredAddressSpaceSinkTests /// public void RebuildAddressSpace() => CallQueue.Enqueue("RB"); /// - public void RaiseNodesAddedModelChange(string affectedNodeId) => CallQueue.Enqueue($"NA:{affectedNodeId}"); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"NA:{affectedNodeId}"); } private sealed class SurgicalRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink @@ -261,14 +261,14 @@ public sealed class DeferredAddressSpaceSinkTests public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls { get; } = new(); /// - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { SurgicalCalls.Add((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength)); return Result; } /// - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { FolderRenameCalls.Add((folderNodeId, displayName)); return Result; @@ -278,25 +278,25 @@ public sealed class DeferredAddressSpaceSinkTests public List<(string Kind, string NodeId)> RemoveCalls { get; } = new(); /// - public bool RemoveVariableNode(string variableNodeId) { RemoveCalls.Add(("var", variableNodeId)); return Result; } + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("var", variableNodeId)); return Result; } /// - public bool RemoveAlarmConditionNode(string alarmNodeId) { RemoveCalls.Add(("alarm", alarmNodeId)); return Result; } + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("alarm", alarmNodeId)); return Result; } /// - public bool RemoveEquipmentSubtree(string equipmentNodeId) { RemoveCalls.Add(("equipment", equipmentNodeId)); return Result; } + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("equipment", equipmentNodeId)); return Result; } /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// public void RebuildAddressSpace() { } /// - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs index 85301fc4..495c8a82 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs @@ -111,11 +111,14 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable condition.ShouldNotBeNull(); // It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm"). condition.ShouldBeOfType(); - condition.NodeId.ShouldBe(new NodeId("alm-1", nm.NamespaceIndex)); + // v3 dual-namespace: the sink's transitional default realm is UNS, so default-realm nodes live in the + // UNS namespace (index 3), not the Raw namespace nm.NamespaceIndex (index 2) reports. + var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns); + condition.NodeId.ShouldBe(new NodeId("alm-1", unsNs)); // Reachable under the equipment folder: the parent is the eq-1 folder (HasComponent child). condition.Parent.ShouldNotBeNull(); - condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", nm.NamespaceIndex)); + condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", unsNs)); // Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false. condition.EnabledState.Id.Value.ShouldBeTrue(); 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 94088347..aeee15cc 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 @@ -324,27 +324,27 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase public int RebuildCalls; /// Records a live-value write. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _values.Enqueue((nodeId, value, quality, sourceTimestampUtc)); /// No-op: alarm writes are not exercised by this suite. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op: alarm materialise is not exercised by this suite. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Records an EnsureFolder call. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _folders.Enqueue((folderNodeId, parentNodeId, displayName)); /// Records an EnsureVariable call. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); /// Records a raw rebuild (the apply-time fallback when no dbFactory is wired). public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); /// Records a NodeAdded model-change announcement. - public void RaiseNodesAddedModelChange(string affectedNodeId) => _modelChanges.Enqueue(affectedNodeId); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId); } } 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 78063069..751d2c11 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 @@ -194,24 +194,24 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase /// The value being written. /// The OPC UA quality status. /// The source timestamp in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++; + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++; /// Records an alarm condition write. /// The alarm node identifier. /// The full condition state snapshot. /// The time the alarm occurred in UTC. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) => Writes++; + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++; /// Materialises an alarm condition (stub implementation). /// The alarm node identifier. /// The equipment folder node identifier. /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Ensures folder exists (stub implementation). /// The folder node identifier. /// The parent folder node identifier. /// The display name for the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Ensures variable exists (stub implementation). /// The variable node identifier. /// The parent folder node identifier. @@ -219,10 +219,10 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Rebuilds address space (recorded via span). public void RebuildAddressSpace() { /* recorded via span */ } /// Announces a NodeAdded model-change (stub implementation). - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } } 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 3bb225fa..1e4256ff 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 @@ -118,25 +118,25 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase /// A sink whose RebuildAddressSpace throws (drives the applier's SafeRebuild catch → RebuildFailed). private sealed class ThrowOnRebuildSink : IOpcUaAddressSpaceSink { - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) { } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault"); - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } /// A no-op sink — a clean apply (no failures). private sealed class NoopSink : IOpcUaAddressSpaceSink { - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) { } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RebuildAddressSpace() { } - public void RaiseNodesAddedModelChange(string affectedNodeId) { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } } /// Listens to a single instrument by name on the central meter and tallies value + tags. 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 04894b13..1a1c43d1 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 @@ -437,13 +437,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// The value to write. /// The OPC UA quality code. /// The timestamp of the write. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"WV:{nodeId}"); /// Records an alarm condition write call. /// The alarm node ID. /// The full condition state snapshot. /// The timestamp of the state change. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"WA:{alarmNodeId}"); /// Records a materialise-alarm-condition call. /// The alarm node ID (== ScriptedAlarmId). @@ -451,13 +451,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"MA:{alarmNodeId}"); /// Records a folder ensure call. /// The folder node ID. /// The parent node ID, or null if this is a root folder. /// The display name of the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"EF:{folderNodeId}"); /// Records a variable ensure call. /// The variable node ID. @@ -466,15 +466,15 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"EV:{variableNodeId}"); /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); /// Records a NodeAdded model-change announcement. /// The node under which discovered nodes were added. - public void RaiseNodesAddedModelChange(string affectedNodeId) => Calls.Enqueue($"NA:{affectedNodeId}"); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"NA:{affectedNodeId}"); /// 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) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { Calls.Enqueue($"UT:{variableNodeId}"); return true; @@ -482,25 +482,25 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// Records a surgical in-place folder display-name update (always succeeds in this recording sink). /// The folder node ID to update in place. /// The new display name to apply. - public bool UpdateFolderDisplayName(string folderNodeId, string displayName) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { FolderRenameQueue.Enqueue((folderNodeId, displayName)); return true; } /// Records a surgical variable-node removal (always succeeds in this recording sink). - public bool RemoveVariableNode(string variableNodeId) + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { Calls.Enqueue($"RV:{variableNodeId}"); return true; } /// Records a surgical alarm-condition-node removal (always succeeds in this recording sink). - public bool RemoveAlarmConditionNode(string alarmNodeId) + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { Calls.Enqueue($"RA:{alarmNodeId}"); return true; } /// Records a surgical equipment-subtree removal (always succeeds in this recording sink). - public bool RemoveEquipmentSubtree(string equipmentNodeId) + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { Calls.Enqueue($"RE:{equipmentNodeId}"); return true; 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 e903b714..7086d3c0 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 @@ -606,14 +606,14 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase /// The attribute value. /// The OPC UA quality code. /// The timestamp of the update. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) => + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ValueQueue.Enqueue((nodeId, value, quality, ts)); /// Records an alarm condition update. /// The OPC UA alarm node identifier. /// The full condition state snapshot. /// The timestamp of the update. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts) => + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => AlarmQueue.Enqueue((alarmNodeId, state, ts)); /// Materialises an alarm condition (no-op in test). @@ -622,13 +622,13 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Records a folder ensure call. /// The OPC UA folder node identifier. /// The parent folder node identifier, or null for root. /// The display name of the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) => + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName)); /// Records a variable ensure call. @@ -638,7 +638,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) => + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); /// Records a rebuild call. @@ -646,7 +646,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) => ModelChangeQueue.Enqueue(affectedNodeId); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId); } /// Test implementation of IServiceLevelPublisher that records publishes. From e95615cef3e4f16457f2e9a9fcaae8162f262404 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 09:52:30 -0400 Subject: [PATCH 04/14] =?UTF-8?q?fix(v3-batch4-wp2):=20AddReference=20sink?= =?UTF-8?q?=20seam=20for=20Organizes=20UNS=E2=86=92Raw=20(Wave=20A=20revie?= =?UTF-8?q?w=20M1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the Wave-A M1 gap: the sink surface had no way to wire the mandated cross-tree Organizes reference from each UNS reference Variable to its backing Raw node, forcing WP3 to reopen the frozen surface. Add a dedicated realm-qualified AddReference method instead. - IOpcUaAddressSpaceSink.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType="Organizes"): realm-qualified both ends; idempotent; a missing endpoint is a logged no-op (never throws) so a mid-rebuild race can't fault a deploy. Base-interface capability (no surgical sniff, no transitional default — WP3 wires it explicitly per UNS reference variable). - SdkAddressSpaceSink forwards to the node manager; DeferredAddressSpaceSink forwards unconditionally (like EnsureFolder); NullOpcUaAddressSpaceSink no-ops. - OtOpcUaNodeManager.AddReference: resolve both nodes by full ns-qualified key (variable/folder/condition via ResolveNodeState), guard both-exist, then wire the edge bidirectionally (forward Organizes on source, inverse on target) with a ReferenceExists idempotency guard + ClearChangeMasks on mutated sides. Reference type resolved by ResolveReferenceType (Organizes default). Added internal GetNodeReferences test/diagnostic accessor. - Reflection guard: the exhaustive-forwarding test + the realm-discriminator guard auto-cover AddReference (it has two AddressSpaceRealm params) — no edit needed. - New SdkAddressSpaceSinkTests: Organizes UNS→Raw edge created bidirectionally + idempotent; missing endpoint is a safe no-op. - All 15 IOpcUaAddressSpaceSink test doubles gain the AddReference no-op so the solution still builds. Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors. Tests: Commons.Tests 310/310; OpcUaServer.Tests 337 passed / 4 pre-existing skips. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../OpcUa/DeferredAddressSpaceSink.cs | 4 + .../OpcUa/IOpcUaAddressSpaceSink.cs | 23 +++++ .../OtOpcUaNodeManager.cs | 91 +++++++++++++++++++ .../SdkAddressSpaceSink.cs | 4 + .../OpcUa/DeferredAddressSpaceSinkTests.cs | 2 + .../AddressSpaceApplierFailureSurfaceTests.cs | 1 + .../AddressSpaceApplierHierarchyTests.cs | 1 + .../AddressSpaceApplierTests.cs | 3 + .../DeferredAddressSpaceSinkTests.cs | 2 + .../SdkAddressSpaceSinkTests.cs | 61 +++++++++++++ .../DiscoveryInjectionEndToEndTests.cs | 1 + .../OtOpcUaTelemetryHookTests.cs | 1 + .../OpcUaPublishActorApplyFailureTests.cs | 2 + .../OpcUa/OpcUaPublishActorRebuildTests.cs | 1 + .../OpcUa/OpcUaPublishActorTests.cs | 1 + 15 files changed, 198 insertions(+) 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 d4e0e39f..c1dbd5b2 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs @@ -48,6 +48,10 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical /// public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm); + /// + public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") + => _inner.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType); + /// // Forwards to the inner sink when it supports the surgical capability; returns false otherwise — // before the real SdkAddressSpaceSink is swapped in (inner is still the null sink), or any inner 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 cfc6800c..64b97dab 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs @@ -111,6 +111,26 @@ public interface IOpcUaAddressSpaceSink /// The namespace realm lives in. // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); + + /// + /// Add an OPC UA reference from an already-materialised source node to an already-materialised + /// target node, both realm-qualified. Used by AddressSpaceApplier to link each UNS reference + /// Variable (source, ) to its backing Raw node (target, + /// ) with an Organizes edge, so the UNS variable + /// browse-resolves to its raw source (and the raw node back via the inverse). The edge is wired + /// bidirectionally (forward on the source, inverse on the target) so browse resolves both ways. + /// Idempotent (an existing edge is not duplicated); a missing endpoint is a no-op + /// (logged, never thrown) so a mid-rebuild race can't fault a deploy. + /// + /// The source node's s= id (the referencing node — e.g. the UNS variable). + /// The namespace realm lives in. + /// The target node's s= id (the referenced node — e.g. the backing Raw node). + /// The namespace realm lives in. + /// The hierarchical reference type name — Organizes (default), + /// HasComponent, or HasProperty; unknown names fall back to Organizes. + void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, + string targetNodeId, AddressSpaceRealm targetRealm, + string referenceType = "Organizes"); } /// OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained @@ -144,4 +164,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink /// public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + + /// + public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index b6375a5e..5f8b6169 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -1736,6 +1736,97 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } + /// + public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, + string targetNodeId, AddressSpaceRealm targetRealm, + string referenceType = "Organizes") + { + ArgumentException.ThrowIfNullOrEmpty(sourceNodeId); + ArgumentException.ThrowIfNullOrEmpty(targetNodeId); + EnsureAddressSpaceCreated(); + + var refType = ResolveReferenceType(referenceType); + var sourceKey = MapKey(sourceRealm, sourceNodeId); + var targetKey = MapKey(targetRealm, targetNodeId); + lock (Lock) + { + var source = ResolveNodeState(sourceKey); + var target = ResolveNodeState(targetKey); + // A missing endpoint is a no-op (logged) — the applier may drive the cross-tree link while a + // concurrent rebuild has torn one side down; never throw out of a deploy-time reference wire. + if (source is null || target is null) + { +#pragma warning disable CS0618 // Utils.LogWarning is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry. + Utils.LogWarning( + "OtOpcUaNodeManager: AddReference({0}) skipped — {1} node missing (source '{2}' present={3}, target '{4}' present={5}).", + refType, source is null ? "source" : "target", sourceKey, source is not null, targetKey, target is not null); +#pragma warning restore CS0618 + return; + } + + // Idempotent: a re-apply must not duplicate the edge. Organizes is hierarchical, so wire it + // bidirectionally — forward on the source, inverse (OrganizedBy) on the target — so browse + // resolves both ways. Only ClearChangeMasks a side we actually mutated. + var sourceChanged = false; + var targetChanged = false; + if (!source.ReferenceExists(refType, isInverse: false, target.NodeId)) + { + source.AddReference(refType, isInverse: false, target.NodeId); + sourceChanged = true; + } + if (!target.ReferenceExists(refType, isInverse: true, source.NodeId)) + { + target.AddReference(refType, isInverse: true, source.NodeId); + targetChanged = true; + } + if (sourceChanged) source.ClearChangeMasks(SystemContext, includeChildren: false); + if (targetChanged) target.ClearChangeMasks(SystemContext, includeChildren: false); + } + } + + /// Resolve a materialised node (variable, folder, or alarm condition) by its full + /// (namespace-qualified) map key, or null when no node is registered under that key. Used by + /// to link two already-materialised nodes regardless of their kind. + /// The namespace-qualified map key (see ). + /// The node state, or null when unknown. + private NodeState? ResolveNodeState(string key) + { + if (_variables.TryGetValue(key, out var variable)) return variable; + if (_folders.TryGetValue(key, out var folder)) return folder; + if (_alarmConditions.TryGetValue(key, out var condition)) return condition; + return null; + } + + /// Test/diagnostic accessor: snapshot the references on a materialised node (by realm-qualified + /// id) using the manager's own SystemContext, or an empty list when the node is unknown. Exposed + /// so SdkAddressSpaceSinkTests can assert the cross-tree Organizes edge + /// wires without needing the (protected) SDK system context. + /// The node's s= id. + /// The realm the node lives in. + /// The node's references, or an empty list when the node is not registered. + internal IReadOnlyList GetNodeReferences(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + { + lock (Lock) + { + var node = ResolveNodeState(MapKey(realm, nodeId)); + if (node is null) return Array.Empty(); + var list = new List(); + node.GetReferences(SystemContext, list); + return list; + } + } + + /// Map a hierarchical reference-type name to its SDK . Unknown names fall + /// back to (the UNS→Raw cross-tree link). + /// The reference-type name (e.g. "Organizes"). + /// The reference-type NodeId. + private static NodeId ResolveReferenceType(string referenceType) => referenceType switch + { + "HasComponent" => ReferenceTypeIds.HasComponent, + "HasProperty" => ReferenceTypeIds.HasProperty, + _ => ReferenceTypeIds.Organizes, + }; + /// Build (but do not report) the Part 3 GeneralModelChangeEvent announcing that nodes were /// added under . MIRRORS exactly — /// the only differences are Verb = NodeAdded (vs DataTypeChanged) and Affected = the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index 65305767..fd8c008d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -65,4 +65,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre /// public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm); + + /// + public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") + => _nodeManager.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType); } 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 33082333..cdbb26b0 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 @@ -195,6 +195,7 @@ public class DeferredAddressSpaceSinkTests public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RebuildAddressSpace() => RebuildCalled = true; public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } private sealed class SpySurgicalSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink @@ -209,6 +210,7 @@ public class DeferredAddressSpaceSinkTests public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RebuildAddressSpace() { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + 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 6e6d0729..6e5aec88 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -189,5 +189,6 @@ public sealed class AddressSpaceApplierFailureSurfaceTests } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + 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 c5210663..214da200 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs @@ -338,5 +338,6 @@ 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 AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } } 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 5d6ff679..1040e634 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 AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } /// A recording sink that does NOT implement — used to @@ -2331,6 +2332,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 AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } private sealed class ThrowingSink : IOpcUaAddressSpaceSink @@ -2379,5 +2381,6 @@ public sealed class AddressSpaceApplierTests public void RebuildAddressSpace() { } /// No-op NodeAdded model-change announcement. public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + 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 2265421e..d73a0b28 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 AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } private sealed class SurgicalRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink @@ -298,5 +299,6 @@ public sealed class DeferredAddressSpaceSinkTests public void RebuildAddressSpace() { } /// public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + 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/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs index 495c8a82..88b11f2c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs @@ -52,6 +52,67 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable await host.DisposeAsync(); } + /// B4-WP2 M1: AddReference wires the cross-tree Organizes edge from a UNS reference variable + /// (source, realm=Uns) to its backing Raw node (target, realm=Raw), bidirectionally, so browse resolves + /// both ways — and is idempotent. + [Fact] + public async Task AddReference_wires_Organizes_edge_from_UNS_variable_to_Raw_node() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + var sink = new SdkAddressSpaceSink(nm); + + // Backing Raw node (device tree) and the UNS reference variable (equipment tree). + sink.EnsureVariable("MAIN/modbus/dev/grp/temp", parentFolderNodeId: null, displayName: "Temp", + dataType: "Float", writable: false, realm: AddressSpaceRealm.Raw); + sink.EnsureVariable("Area/Line/Eq/Temperature", parentFolderNodeId: null, displayName: "Temperature", + dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns); + + sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns, + "MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw); + + var unsVar = nm.TryGetVariable("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns); + var rawVar = nm.TryGetVariable("MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw); + unsVar.ShouldNotBeNull(); + rawVar.ShouldNotBeNull(); + + // Forward Organizes on the UNS variable → Raw node; inverse (OrganizedBy) on the Raw node. + unsVar.ReferenceExists(ReferenceTypeIds.Organizes, isInverse: false, rawVar.NodeId).ShouldBeTrue(); + rawVar.ReferenceExists(ReferenceTypeIds.Organizes, isInverse: true, unsVar.NodeId).ShouldBeTrue(); + + // Idempotent: a second identical call does not duplicate the edge. The UNS variable's only + // FORWARD Organizes ref is the one we added (its parent link manifests as an inverse ref). + sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns, + "MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw); + nm.GetNodeReferences("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns) + .Count(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && !r.IsInverse).ShouldBe(1); + + await host.DisposeAsync(); + } + + /// B4-WP2 M1: a missing endpoint makes AddReference a safe no-op (never throws, no edge added). + [Fact] + public async Task AddReference_with_missing_endpoint_is_a_safe_no_op() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + var sink = new SdkAddressSpaceSink(nm); + + sink.EnsureVariable("Area/Line/Eq/Temperature", parentFolderNodeId: null, displayName: "Temperature", + dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns); + + // Target does not exist ⇒ no-op, no throw. + Should.NotThrow(() => sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns, + "MAIN/does/not/exist", AddressSpaceRealm.Raw)); + + var unsVar = nm.TryGetVariable("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns); + unsVar.ShouldNotBeNull(); + nm.GetNodeReferences("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns) + .Any(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && !r.IsInverse).ShouldBeFalse(); + + await host.DisposeAsync(); + } + /// Verifies that RebuildAddressSpace clears all registered variables. [Fact] public async Task RebuildAddressSpace_clears_all_registered_variables() 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 aeee15cc..3a80d6ec 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,5 +346,6 @@ 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 AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } } 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 751d2c11..6759ee96 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,5 +224,6 @@ 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 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 1e4256ff..a97da8f0 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, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault"); public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } /// A no-op sink — a clean apply (no failures). @@ -137,6 +138,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void RebuildAddressSpace() { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } /// Listens to a single instrument by name on the central meter and tallies value + tags. 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 1a1c43d1..1d2731db 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 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 7086d3c0..c9c25bc3 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 AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } /// Test implementation of IServiceLevelPublisher that records publishes. From 3efcf8014b37852c31c10d6f1cfd4368d52b2960 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 10:56:31 -0400 Subject: [PATCH 05/14] feat(v3-batch4-wp3): raw-only binding + UNS fan-out + write routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave B of Batch 4 — the runtime binding seam for the dual namespace. Applier (both realms, explicit realm at every sink call site): - MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath), all in AddressSpaceRealm.Raw; historian tagname = override else RawPath. - MaterialiseUnsReferences: each UNS reference Variable under its equipment folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/ historian tagname from the backing raw tag (both NodeIds -> one tagname). - FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS refs in place (raw-container removal falls back to rebuild). DriverHostActor (dual-NodeId, single-source fan-out): - _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans to the raw NodeId AND every referencing UNS NodeId with identical value/quality/timestamp. Write inverse map keyed by the bare id; the ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a write to either NodeId resolves the same driver ref (-> RawPath write). - Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate + AlarmStateUpdate carry the realm through to the sink. Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now). DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity with the composer (reconstruct entities -> AddressSpaceComposer.Compose). Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink / DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now explicit (realm reordered before the trailing optionals on EnsureVariable + MaterialiseAlarmCondition). Node-manager convenience methods keep their defaults (they are not the interface impl; Sdk delegates explicitly). Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) + DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3 raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../OpcUa/DeferredAddressSpaceSink.cs | 26 +- .../OpcUa/EquipmentNodeIds.cs | 31 -- .../OpcUa/IOpcUaAddressSpaceSink.cs | 30 +- .../OpcUa/ISurgicalAddressSpaceSink.cs | 15 +- .../AddressSpaceApplier.cs | 309 +++++++++++++----- .../SdkAddressSpaceSink.cs | 22 +- .../Drivers/DeploymentArtifact.cs | 154 +++++++++ .../Drivers/DiscoveredNodeMapper.cs | 23 +- .../Drivers/DriverHostActor.cs | 203 +++++++----- .../OpcUa/OpcUaPublishActor.cs | 27 +- .../VirtualTags/VirtualTagHostActor.cs | 12 +- .../OpcUa/DeferredAddressSpaceSinkTests.cs | 44 +-- .../OpcUa/EquipmentNodeIdsTests.cs | 28 -- .../SubscriptionSurvivalTests.cs | 33 +- .../AddressSpaceApplierFailureSurfaceTests.cs | 4 +- .../AddressSpaceApplierHierarchyTests.cs | 4 +- .../AddressSpaceApplierProvisioningTests.cs | 25 +- .../AddressSpaceApplierRawUnsTests.cs | 177 ++++++++++ .../AddressSpaceApplierTests.cs | 72 ++-- .../DeferredAddressSpaceSinkTests.cs | 60 ++-- .../SdkAddressSpaceSinkTests.cs | 82 ++--- .../DiscoveryInjectionEndToEndTests.cs | 4 +- .../Drivers/DriverHostActorDiscoveryTests.cs | 4 +- .../Drivers/DriverHostActorLiveValueTests.cs | 227 ++++++++----- .../DriverHostActorWriteRoutingTests.cs | 276 +++++----------- .../AddHistorianProvisioningTests.cs | 20 +- .../OtOpcUaTelemetryHookTests.cs | 4 +- .../OpcUaPublishActorApplyFailureTests.cs | 8 +- .../OpcUa/OpcUaPublishActorRebuildTests.cs | 4 +- .../OpcUa/OpcUaPublishActorTests.cs | 4 +- 30 files changed, 1186 insertions(+), 746 deletions(-) delete mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/EquipmentNodeIds.cs delete mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/EquipmentNodeIdsTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs 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 c1dbd5b2..068c42a9 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs @@ -23,30 +23,30 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical _inner = sink ?? NullOpcUaAddressSpaceSink.Instance; /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) => _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm); /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) => _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm); /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) - => _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative, realm); + 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); /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) - => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm); + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); /// public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); /// - public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm); /// public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") @@ -59,7 +59,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical // Without this forward the surgical optimization is inert on every driver-role host, because // actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape // ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host. - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) => _inner is ISurgicalAddressSpaceSink surgical && surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm); @@ -69,7 +69,7 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical // sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild. // Without this forward the rename-refresh optimization is inert on every driver-role host, because // actors inject THIS wrapper, not the inner sink. - public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) => _inner is ISurgicalAddressSpaceSink surgical && surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm); @@ -79,14 +79,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical // (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is // inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch. /// - public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm); /// - public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm); /// - public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm) => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId, realm); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/EquipmentNodeIds.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/EquipmentNodeIds.cs deleted file mode 100644 index 48942008..00000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/EquipmentNodeIds.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; - -/// -/// Single source of truth for equipment-namespace OPC UA NodeId strings. The variable NodeId is -/// FOLDER-SCOPED ({parent}/{Name}), NOT the driver-side FullName — a driver wire ref is not -/// unique across identical machines, so FullName-as-NodeId would collide in the sink. Used by the -/// materialiser (AddressSpaceApplier), the VirtualTag publish map, and the driver live-value router so all -/// three agree on the exact NodeId a variable was placed at. -/// -public static class EquipmentNodeIds -{ - /// The sub-folder NodeId under an equipment for a non-empty FolderPath: {equipmentId}/{folderPath}. - /// The owning equipment's NodeId. - /// The tag/vtag FolderPath (must be non-empty for this to be meaningful). - /// The sub-folder NodeId string. - public static string SubFolder(string equipmentId, string folderPath) => $"{equipmentId}/{folderPath}"; - - /// - /// The folder-scoped variable NodeId: {parent}/{name} where parent = equipmentId when - /// is null/empty, else . - /// - /// The owning equipment's NodeId. - /// The tag/vtag FolderPath, or null/empty for "directly under the equipment". - /// The tag/vtag Name (the leaf browse segment). - /// The folder-scoped variable NodeId string. - public static string Variable(string equipmentId, string? folderPath, string name) - { - var parent = string.IsNullOrWhiteSpace(folderPath) ? equipmentId : SubFolder(equipmentId, folderPath); - return $"{parent}/{name}"; - } -} 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 64b97dab..57225516 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs @@ -17,8 +17,7 @@ public interface IOpcUaAddressSpaceSink /// equipment tree) the lives in — the sink /// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out /// calls this once per NodeId (raw + every referencing UNS node) with the matching realm. - // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. - void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns); + void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm); /// Write an alarm-condition's full Part 9 state. When a real condition node has been /// materialised for via , @@ -32,8 +31,7 @@ public interface IOpcUaAddressSpaceSink /// The source timestamp in UTC. /// The namespace realm lives in (must match the realm /// the condition was materialised under). - // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. - void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns); + void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm); /// /// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients @@ -51,8 +49,7 @@ public interface IOpcUaAddressSpaceSink /// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine. /// The namespace realm the condition (and its parent folder ) /// live in. - // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. - void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns); + void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false); /// /// Ensure a folder node exists under the given parent. Used by AddressSpaceApplier to @@ -64,8 +61,7 @@ public interface IOpcUaAddressSpaceSink /// The parent folder node ID, or null for namespace root. /// The display name for the folder. /// The namespace realm the folder (and its ) live in. - // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. - void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns); + void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm); /// /// Ensure a Variable node exists at , parented under @@ -93,8 +89,7 @@ public interface IOpcUaAddressSpaceSink /// The namespace realm the variable (and its ) live /// in. A historized UNS reference node registers the SAME as its backing /// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm. - // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. - void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns); + void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null); /// /// Tear down + repopulate the address space. Called by OpcUaPublishActor after a @@ -109,8 +104,7 @@ public interface IOpcUaAddressSpaceSink /// /// The node under which discovered nodes were added. /// The namespace realm lives in. - // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. - void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); + void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm); /// /// Add an OPC UA reference from an already-materialised source node to an already-materialised @@ -145,25 +139,25 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink private NullOpcUaAddressSpaceSink() { } /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { } /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + 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 RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { } /// public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs index 656fcb78..8d490a1f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs @@ -24,8 +24,7 @@ public interface ISurgicalAddressSpaceSink /// The namespace realm lives in — the sink resolves the /// namespace index from the realm rather than parsing it out of the id string. /// True when the in-place update was applied; false when the node is missing (caller rebuilds). - // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. - bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns); + bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm); /// Update an existing folder node's DisplayName IN PLACE, notifying subscribers /// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line @@ -37,8 +36,7 @@ public interface ISurgicalAddressSpaceSink /// The new display name to apply. /// The namespace realm lives in. /// True when the in-place update was applied; false when the folder is missing (caller rebuilds). - // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. - bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns); + bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm); /// Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the /// predefined-node set, drop any historized-tagname registration), then raise a Part 3 @@ -50,8 +48,7 @@ public interface ISurgicalAddressSpaceSink /// The folder-scoped node id of the variable to remove in place. /// The namespace realm lives in. /// True when the node was removed; false when the id is unknown (caller rebuilds). - // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. - bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); + bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm); /// Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the /// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The @@ -61,8 +58,7 @@ public interface ISurgicalAddressSpaceSink /// The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id). /// The namespace realm lives in. /// True when the condition was removed; false when the id is unknown (caller rebuilds). - // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. - bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); + bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm); /// Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables, /// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames, @@ -73,6 +69,5 @@ public interface ISurgicalAddressSpaceSink /// The equipment folder node id whose entire subtree to remove. /// The namespace realm lives in. /// True when the subtree was removed; false when the id is unknown (caller rebuilds). - // transitional default — B4-WP3/Wave B makes every call site explicit and removes this default. - bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns); + bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index a4f72c49..277f824b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; @@ -96,29 +97,33 @@ public sealed class AddressSpaceApplier var failedNodes = 0; foreach (var eq in plan.RemovedEquipment) { - if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts)) failedNodes++; + if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++; removedCount++; } foreach (var alarm in plan.RemovedAlarms) { - if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts)) failedNodes++; + if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++; removedCount++; } // Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write // before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes - // is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. - removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count; + // is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4: + // removed raw containers/tags + UNS reference variables are likewise real removals. + removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count + + plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count; var changedCount = plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count + plan.ChangedEquipmentTags.Count + plan.ChangedEquipmentVirtualTags.Count + + plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count + // A UNS Area/Line rename is an in-place change to an existing folder node. plan.RenamedFolders.Count; var addedCount = plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count + plan.AddedEquipmentTags.Count + - plan.AddedEquipmentVirtualTags.Count; + plan.AddedEquipmentVirtualTags.Count + + plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count; // R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of // rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over @@ -172,7 +177,7 @@ public sealed class AddressSpaceApplier foreach (var rename in renamedFolders) { bool ok; - try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName); } + try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName, AddressSpaceRealm.Uns); } catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId); @@ -185,13 +190,13 @@ public sealed class AddressSpaceApplier // Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags // would so the in-place update matches what a rebuild would have produced. Array tags are // forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays). - var nodeId = EquipmentNodeIds.Variable(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name); + var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name); var writable = d.Current.Writable && !d.Current.IsArray; var historian = d.Current.IsHistorized ? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname) : null; bool ok; - try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength); } + try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); } catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId); @@ -263,77 +268,108 @@ public sealed class AddressSpaceApplier // Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment. foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId))) { - var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name); + var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name); if (tag.Alarm is not null) { // Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap // closed), then remove the condition in place. - if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts)) failedNodes++; - if (!SafeRemoveAlarmCondition(surgical, nodeId)) return false; + if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++; + if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false; } else { // Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal. - SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts); - if (!SafeRemoveVariable(surgical, nodeId)) return false; + SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns); + if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false; } } // Removed VirtualTags (always plain value variables) for surviving equipment. foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId))) { - var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name); - SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts); - if (!SafeRemoveVariable(surgical, nodeId)) return false; + var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name); + SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns); + if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false; } // Removed scripted alarms for surviving equipment — the terminal RemovedConditionState was already // written by the top-of-Apply removal block; here we tear the condition node down in place. foreach (var alarm in plan.RemovedAlarms.Where(a => NotSubsumed(a.EquipmentId))) { - if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId)) return false; + if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId, AddressSpaceRealm.Uns)) return false; } // Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The // top-of-Apply block already wrote the equipment id's terminal condition state. foreach (var eq in plan.RemovedEquipment) { - if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId)) return false; + if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId, AddressSpaceRealm.Uns)) return false; } + // v3 Batch 4 — removed UNS reference variables (Uns realm, plain value nodes): terminal Bad then + // in-place remove. A backing-raw rename is a re-point (Changed, → rebuild), NOT a removal, so a UNS + // ref only reaches here on a genuine dereference (the equipment dropped the reference). + foreach (var v in plan.RemovedUnsReferenceVariables) + { + SafeWriteValue(v.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns); + if (!SafeRemoveVariable(surgical, v.NodeId, AddressSpaceRealm.Uns)) return false; + } + + // v3 Batch 4 — removed RAW tags (Raw realm): an alarm-bearing raw tag is a condition node (terminal + // RemovedConditionState + RemoveAlarmConditionNode); a plain raw value tag is a variable (terminal Bad + // + RemoveVariableNode). + foreach (var t in plan.RemovedRawTags) + { + if (t.Alarm is not null) + { + if (!SafeWriteAlarmCondition(t.NodeId, RemovedConditionState, ts, AddressSpaceRealm.Raw)) failedNodes++; + if (!SafeRemoveAlarmCondition(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false; + } + else + { + SafeWriteValue(t.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Raw); + if (!SafeRemoveVariable(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false; + } + } + + // Removed raw CONTAINER nodes (Folder/Driver/Device/TagGroup) have no surgical folder-remove on the + // sink surface, so any container removal falls back to a full rebuild (the ratchet). Evaluated LAST so + // the cheap variable/condition removals above still run in place when only tags were removed. + if (plan.RemovedRawContainers.Count > 0) return false; + return true; } /// Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback). /// true when the node was removed; false when unknown or the sink threw. - private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId) + private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm) { - try { return surgical.RemoveVariableNode(nodeId); } + try { return surgical.RemoveVariableNode(nodeId, realm); } catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveVariableNode threw for {Node}", nodeId); return false; } } /// Remove an alarm-condition node in place, treating a throw as a false (⇒ rebuild fallback). /// true when the node was removed; false when unknown or the sink threw. - private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId) + private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm) { - try { return surgical.RemoveAlarmConditionNode(nodeId); } + try { return surgical.RemoveAlarmConditionNode(nodeId, realm); } catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveAlarmConditionNode threw for {Node}", nodeId); return false; } } /// Remove an equipment subtree in place, treating a throw as a false (⇒ rebuild fallback). /// true when the subtree was removed; false when unknown or the sink threw. - private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId) + private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm) { - try { return surgical.RemoveEquipmentSubtree(nodeId); } + try { return surgical.RemoveEquipmentSubtree(nodeId, realm); } catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveEquipmentSubtree threw for {Node}", nodeId); return false; } } /// Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad /// on a removed variable so an in-flight MonitoredItem observes the removal. /// true when the write landed; false when the sink threw. - private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) + private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm) { - try { _sink.WriteValue(nodeId, value, quality, ts); return true; } + try { _sink.WriteValue(nodeId, value, quality, ts, realm); return true; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; } } @@ -373,7 +409,22 @@ public sealed class AddressSpaceApplier /// The tag/vtag FolderPath, or null/empty for "directly under the equipment". /// The materialise-parent node id. private static string MaterialiseParent(string equipmentId, string? folderPath) => - string.IsNullOrWhiteSpace(folderPath) ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, folderPath); + string.IsNullOrWhiteSpace(folderPath) ? equipmentId : UnsSubFolder(equipmentId, folderPath); + + // ----- v3 UNS-equipment NodeId helpers (replace the retired EquipmentNodeIds) ----- + // Equipment-owned UNS nodes (per-equipment VirtualTags, the vestigial equipment-tag path, and the + // discovered-node graft) keep their EquipmentId-anchored slash-path NodeId ({equipmentId}/{folderPath}/{name}), + // now expressed through the v3 identity authority V3NodeIds.Uns so the retired EquipmentNodeIds type can go. + // These live in the UNS realm. FolderPath may be multi-segment ("a/b") — each segment is validated + // by V3NodeIds.Uns like a RawPath. (UnsTagReference variables use the composer's Area/Line/Equipment/Name + // NodeId directly and are NOT built here.) + private static string UnsSubFolder(string equipmentId, string folderPath) => + V3NodeIds.Uns(new[] { equipmentId }.Concat(folderPath.Split(RawPaths.Separator))); + + private static string UnsEquipmentVar(string equipmentId, string? folderPath, string name) => + string.IsNullOrWhiteSpace(folderPath) + ? V3NodeIds.Uns(equipmentId, name) + : V3NodeIds.Uns(new[] { equipmentId }.Concat(folderPath.Split(RawPaths.Separator)).Append(name)); /// /// Announce a Part 3 GeneralModelChangeEvent(NodeAdded) per distinct affected parent from @@ -389,7 +440,7 @@ public sealed class AddressSpaceApplier { foreach (var id in ComputeAddAnnouncements(plan)) { - try { _sink.RaiseNodesAddedModelChange(id); } + try { _sink.RaiseNodesAddedModelChange(id, AddressSpaceRealm.Uns); } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); } } } @@ -407,11 +458,11 @@ public sealed class AddressSpaceApplier try { List? requests = null; - foreach (var tag in plan.AddedEquipmentTags) + // v3 Batch 4: historized value tags are RAW tags (added-raw-tag delta). Native-alarm tags + // materialise as Part 9 conditions (never historized value variables), so mirror the materialiser + // and skip them. + foreach (var tag in plan.AddedRawTags) { - // Only historized value variables are provisioned. Native-alarm tags materialise as - // Part 9 condition nodes (never historized value variables) — the materialiser resolves - // a historian tagname only for the non-alarm branch, so mirror that and skip them. if (!tag.IsHistorized || tag.Alarm is not null) continue; // Parse the driver-agnostic data type from the tag's DataType string. An unparseable @@ -424,9 +475,9 @@ public sealed class AddressSpaceApplier continue; } - // Resolve the historian name EXACTLY as MaterialiseEquipmentTags does: a null/blank - // override falls back to the driver-side FullName. - var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname; + // Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override + // falls back to the RawPath (== NodeId). + var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname; (requests ??= new List()).Add( new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name)); } @@ -493,22 +544,26 @@ public sealed class AddressSpaceApplier List? added = null; List? removed = null; - // Added historized value variables → new interest. - foreach (var tag in plan.AddedEquipmentTags) + // v3 Batch 4: historized value tags are RAW tags (keyed by RawPath). The mux HistorizedTagRef + // stays SINGLE, keyed by RawPath — a UNS reference node registers the same historian tagname in the + // node manager but does NOT add a second mux ref (no double historization). So this feed emits raw + // refs only. + // Added historized raw value tags → new interest. + foreach (var tag in plan.AddedRawTags) { if (HistorizedRef(tag) is { } r) (added ??= new List()).Add(r); } - // Removed historized value variables → drop interest. - foreach (var tag in plan.RemovedEquipmentTags) + // Removed historized raw value tags → drop interest. + foreach (var tag in plan.RemovedRawTags) { if (HistorizedRef(tag) is { } r) (removed ??= new List()).Add(r); } - // Changed tags: the historized ref may have flipped on/off or been renamed (override/FullName - // change). Compare previous-vs-current resolved ref PAIRS (record equality compares both the - // mux ref and the historian name) — an unchanged pair is a no-op. - foreach (var d in plan.ChangedEquipmentTags) + // Changed raw tags: the historized ref may have flipped on/off or the historian override changed + // (a rename is remove+add, handled above). Compare previous-vs-current resolved ref PAIRS — an + // unchanged pair is a no-op. + foreach (var d in plan.ChangedRawTags) { var prev = HistorizedRef(d.Previous); var cur = HistorizedRef(d.Current); @@ -540,13 +595,15 @@ public sealed class AddressSpaceApplier /// two diverge ONLY when an override is set. Returns null when the tag is not a historized /// value variable (not historized, or a native-alarm condition node). /// - /// The equipment tag to resolve a historized ref for. + /// The raw tag to resolve a historized ref for. /// The resolved historized ref pair, or null when the tag is not a historized value variable. - private static HistorizedTagRef? HistorizedRef(EquipmentTagPlan tag) => + private static HistorizedTagRef? HistorizedRef(RawTagPlan tag) => tag.IsHistorized && tag.Alarm is null + // v3: the RawPath (== NodeId) is BOTH the mux ref (the driver fans values by RawPath) and the + // default historian tagname; an override diverges only the historian name. ? new HistorizedTagRef( - tag.FullName, - string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) + tag.NodeId, + string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname) : null; /// Rebuild the sink's address space, swallowing (and Error-logging) any fault. @@ -585,17 +642,17 @@ public sealed class AddressSpaceApplier var failed = 0; foreach (var area in composition.UnsAreas) { - if (!SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName)) failed++; + if (!SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName, realm: AddressSpaceRealm.Uns)) failed++; } foreach (var line in composition.UnsLines) { - if (!SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName)) failed++; + if (!SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName, realm: AddressSpaceRealm.Uns)) failed++; } foreach (var equipment in composition.EquipmentNodes) { // Equipment with no UnsLineId (legacy / dev rows) hang under the root. var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId; - if (!SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName)) failed++; + if (!SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName, realm: AddressSpaceRealm.Uns)) failed++; } _logger.LogInformation( @@ -604,6 +661,112 @@ public sealed class AddressSpaceApplier return failed; } + /// + /// v3 Batch 4 — materialise the Raw device-oriented subtree (ns=Raw): the container + /// nodes (Folder→Driver→Device→TagGroup, each an Object/Folder) parents-before-children, + /// then the tag Variable nodes keyed s=<RawPath>. A native-alarm tag ( + /// non-null) materialises as a Part 9 condition node at its RawPath (ConditionId = RawPath, parent = its + /// device/group folder) instead of a value variable — the single condition instance; WP4 wires the extra + /// per-equipment notifiers. A historized value tag resolves its effective historian tagname HERE + /// (override else the RawPath). Array tags are forced read-only (the driver write path can't handle + /// arrays). Every sink call passes EXPLICITLY — the driver binds + /// values to these raw NodeIds, and every referencing UNS node fans out from them. Idempotent. + /// + /// The composition carrying the Raw subtree. + /// The count of swallowed sink failures in this pass. + public int MaterialiseRawSubtree(AddressSpaceComposition composition) + { + ArgumentNullException.ThrowIfNull(composition); + if (composition.RawContainers.Count == 0 && composition.RawTags.Count == 0) return 0; + + var failed = 0; + // Containers first — the composer sorts them by NodeId (ordinal) so a parent's RawPath precedes each + // child's; EnsureFolder is idempotent, so a re-apply is cheap. + foreach (var c in composition.RawContainers) + { + if (!SafeEnsureFolder(c.NodeId, c.ParentNodeId, c.DisplayName, AddressSpaceRealm.Raw)) failed++; + } + + 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++; + } + else + { + // A historized tag materialises Historizing + HistoryRead; the effective historian tagname is + // the override else the RawPath (== NodeId). Array writes are out of scope → force read-only. + string? historianTagname = t.IsHistorized + ? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname) + : null; + var writable = t.Writable && !t.IsArray; + if (!SafeEnsureVariable(t.NodeId, t.ParentNodeId, t.Name, t.DataType, writable, AddressSpaceRealm.Raw, historianTagname, t.IsArray, t.ArrayLength)) failed++; + } + } + + _logger.LogInformation( + "AddressSpaceApplier: raw subtree materialised (containers={Containers}, tags={Tags}, failed={Failed})", + composition.RawContainers.Count, composition.RawTags.Count, failed); + return failed; + } + + /// + /// v3 Batch 4 — materialise the UNS reference Variable nodes (ns=UNS): one per + /// , keyed s=<Area>/<Line>/<Equipment>/<EffectiveName>, + /// parented under its equipment folder (the logical node + /// already created), THEN an Organizes reference + /// UNS→Raw to its backing raw node. The UNS node inherits DataType / writable / array shape / historian + /// tagname from its backing RAW tag (looked up by ), so a + /// historized raw tag's UNS reference registers the SAME tagname (both NodeIds → one tagname → HistoryRead + /// works against either). The UNS node binds NO driver — the driver publish path fans values to it (WP3 + /// runtime binding). Every sink call passes / the Organizes edge + /// passes both realms EXPLICITLY. Idempotent. + /// + /// The composition carrying the UNS reference variables + Raw tags (for inherited shape). + /// The count of swallowed sink failures in this pass. + public int MaterialiseUnsReferences(AddressSpaceComposition composition) + { + ArgumentNullException.ThrowIfNull(composition); + if (composition.UnsReferenceVariables.Count == 0) return 0; + + // Backing raw tag shape/historian by RawPath — a UNS reference inherits these so its node matches the + // raw node it mirrors (a historized raw tag registers the SAME tagname on the UNS node for HistoryRead). + var rawByPath = new Dictionary(StringComparer.Ordinal); + foreach (var t in composition.RawTags) rawByPath[t.NodeId] = t; + + var failed = 0; + foreach (var v in composition.UnsReferenceVariables) + { + var backing = rawByPath.GetValueOrDefault(v.BackingRawPath); + var isArray = backing?.IsArray ?? false; + uint? arrayLength = backing?.ArrayLength; + // A UNS reference to a historized raw tag registers the SAME effective historian tagname (override + // else the backing RawPath) so HistoryRead resolves against the UNS NodeId too. The mux ref stays + // single (keyed by RawPath) — FeedHistorizedRefs emits raw refs only. + string? historianTagname = backing is { IsHistorized: true } + ? (string.IsNullOrWhiteSpace(backing.HistorianTagname) ? backing.NodeId : backing.HistorianTagname) + : null; + var writable = v.Writable && !isArray; + if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength)) + { + failed++; + continue; // node not created — skip the Organizes edge (a missing endpoint would no-op anyway) + } + // Organizes UNS→Raw so the linkage is browsable. Both endpoints are realm-qualified; a missing + // endpoint is a logged no-op in the sink (never throws), so this can't fault a deploy. + try { _sink.AddReference(v.NodeId, AddressSpaceRealm.Uns, v.BackingRawPath, AddressSpaceRealm.Raw); } + catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: AddReference (Organizes UNS->Raw) threw for {Node}", v.NodeId); failed++; } + } + + _logger.LogInformation( + "AddressSpaceApplier: UNS reference variables materialised (refs={Refs}, failed={Failed})", + composition.UnsReferenceVariables.Count, failed); + return failed; + } + /// /// Materialise Equipment-namespace tags from a composition snapshot. /// For each , @@ -635,9 +798,9 @@ public sealed class AddressSpaceApplier foreach (var tag in composition.EquipmentTags) { if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue; - var folderNodeId = EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath); + var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath); if (!foldersCreated.Add(folderNodeId)) continue; - if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath)) failed++; + if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++; } // Variables: NodeId is FOLDER-SCOPED ("/"), NOT the raw FullName — a driver @@ -650,13 +813,13 @@ public sealed class AddressSpaceApplier { var parent = string.IsNullOrWhiteSpace(tag.FolderPath) ? tag.EquipmentId - : EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath); - var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name); + : UnsSubFolder(tag.EquipmentId, tag.FolderPath); + var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name); if (tag.Alarm is not null) { // Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path), // NOT a value variable. Parent is the sub-folder when set, else the equipment folder. - if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true)) failed++; + if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++; } else { @@ -670,7 +833,7 @@ public sealed class AddressSpaceApplier // even if authored ReadWrite, so a client write cannot reach the driver write path which // does not handle arrays (e.g. S7 BoxValueForWrite would crash). var writable = tag.Writable && !tag.IsArray; - if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, historianTagname, tag.IsArray, tag.ArrayLength)) failed++; + if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++; } } @@ -708,17 +871,17 @@ public sealed class AddressSpaceApplier var failed = 0; // Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth. foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/'))) - if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName)) failed++; + if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName, AddressSpaceRealm.Raw)) failed++; foreach (var v in variables) { // Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays). var writable = v.Writable && !v.IsArray; if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable, - historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++; + AddressSpaceRealm.Raw, historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++; } - _sink.RaiseNodesAddedModelChange(equipmentRootNodeId); + _sink.RaiseNodesAddedModelChange(equipmentRootNodeId, AddressSpaceRealm.Raw); _logger.LogInformation( "AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})", @@ -754,9 +917,9 @@ public sealed class AddressSpaceApplier foreach (var v in composition.EquipmentVirtualTags) { if (string.IsNullOrWhiteSpace(v.FolderPath)) continue; - var folderNodeId = EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath); + var folderNodeId = UnsSubFolder(v.EquipmentId, v.FolderPath); if (!foldersCreated.Add(folderNodeId)) continue; - if (!SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath)) failed++; + if (!SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath, realm: AddressSpaceRealm.Uns)) failed++; } // Variables: NodeId is FOLDER-SCOPED ("/"), mirroring the equipment-tag pass. @@ -769,10 +932,10 @@ public sealed class AddressSpaceApplier { var parent = string.IsNullOrWhiteSpace(v.FolderPath) ? v.EquipmentId - : EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath); - var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name); + : UnsSubFolder(v.EquipmentId, v.FolderPath); + var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name); // VirtualTags are computed outputs — read-only nodes (no inbound write). - if (!SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false)) failed++; + if (!SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false, realm: AddressSpaceRealm.Uns)) failed++; } _logger.LogInformation( @@ -805,7 +968,7 @@ public sealed class AddressSpaceApplier foreach (var alarm in composition.EquipmentScriptedAlarms) { if (!alarm.Enabled) continue; - if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false)) failed++; + if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false, realm: AddressSpaceRealm.Uns)) failed++; materialised++; } @@ -822,9 +985,9 @@ public sealed class AddressSpaceApplier /// on success, false when the sink threw — callers tally the false into their pass's failed-node /// count so a swallowed materialise failure is operator-visible (archreview 01/S-1). /// true when the folder was ensured; false when the sink threw. - private bool SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName) + private bool SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { - try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); return true; } + try { _sink.EnsureFolder(nodeId, parentNodeId, displayName, realm); return true; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureFolder threw for {Node}", nodeId); return false; } } @@ -832,9 +995,9 @@ public sealed class AddressSpaceApplier /// 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 variable was ensured; false when the sink threw. - private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { - try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); return true; } + try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); return true; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; } } @@ -859,9 +1022,9 @@ public sealed class AddressSpaceApplier /// Returns true on success, false when the sink threw — the removal pass tallies the /// false into (archreview 01/S-1). /// true when the write landed; false when the sink threw. - private bool SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts) + private bool SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm) { - try { _sink.WriteAlarmCondition(nodeId, state, ts); return true; } + try { _sink.WriteAlarmCondition(nodeId, state, ts, realm); return true; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteAlarmCondition threw for {Node}", nodeId); return false; } } @@ -869,9 +1032,9 @@ public sealed class AddressSpaceApplier /// 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 condition was materialised; false when the sink threw. - private bool SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative) + private bool SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative, AddressSpaceRealm realm) { - try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); return true; } + 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; } } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index fd8c008d..87927ae7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -21,50 +21,50 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre } /// - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) => _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm); /// - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) => _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm); /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + 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, isNative, realm); /// - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) => _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm); /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm); /// - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) => _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm); /// - public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) => _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName, realm); /// - public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) => _nodeManager.RemoveVariableNode(variableNodeId, realm); /// - public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) => _nodeManager.RemoveAlarmConditionNode(alarmNodeId, realm); /// - public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm) => _nodeManager.RemoveEquipmentSubtree(equipmentNodeId, realm); /// public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace(); /// - public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm); /// public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index e47c9c1e..d79d0b08 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.OpcUaServer; @@ -253,6 +254,142 @@ public static class DeploymentArtifact return EquipmentReferenceMap.Build(references, tagsById, resolver); } + /// + /// v3 Batch 4 — reconstruct the raw/UNS entities from the artifact JSON and drive the pure + /// , returning ONLY the three dual-namespace lists + /// ( / / + /// ). Reusing the composer verbatim is what + /// guarantees BYTE-PARITY between the live-edit compose seam and this artifact-decode seam — the same + /// RawPathResolver / RawPaths / TagConfigIntent / V3NodeIds authorities + /// produce every RawPath, UNS NodeId, and Organizes backing path. Enums serialize numerically in the + /// artifact (no JsonStringEnumConverter), so AccessLevel is read as an int. + /// + /// The artifact root element. + /// The Raw containers, Raw tags, and UNS reference variables. + private static (IReadOnlyList Containers, IReadOnlyList Tags, IReadOnlyList UnsRefs) + BuildRawAndUnsSubtrees(JsonElement root) + { + var rawFolders = new List(); + foreach (var el in EnumerateArray(root, "RawFolders")) + { + var id = ReadString(el, "RawFolderId"); + if (string.IsNullOrWhiteSpace(id)) continue; + rawFolders.Add(new RawFolder + { + RawFolderId = id!, ParentRawFolderId = ReadNullableString(el, "ParentRawFolderId"), + Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty, + }); + } + + var driverInstances = new List(); + foreach (var el in EnumerateArray(root, "DriverInstances")) + { + var id = ReadString(el, "DriverInstanceId"); + if (string.IsNullOrWhiteSpace(id)) continue; + driverInstances.Add(new DriverInstance + { + DriverInstanceId = id!, RawFolderId = ReadNullableString(el, "RawFolderId"), + Name = ReadString(el, "Name") ?? id!, DriverType = ReadString(el, "DriverType") ?? string.Empty, + DriverConfig = ReadString(el, "DriverConfig") ?? "{}", ClusterId = ReadString(el, "ClusterId") ?? string.Empty, + }); + } + + var devices = new List(); + foreach (var el in EnumerateArray(root, "Devices")) + { + var id = ReadString(el, "DeviceId"); + var di = ReadString(el, "DriverInstanceId"); + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue; + devices.Add(new Device + { + DeviceId = id!, DriverInstanceId = di!, Name = ReadString(el, "Name") ?? id!, + DeviceConfig = ReadString(el, "DeviceConfig") ?? "{}", + }); + } + + var tagGroups = new List(); + foreach (var el in EnumerateArray(root, "TagGroups")) + { + var id = ReadString(el, "TagGroupId"); + if (string.IsNullOrWhiteSpace(id)) continue; + tagGroups.Add(new TagGroup + { + TagGroupId = id!, ParentTagGroupId = ReadNullableString(el, "ParentTagGroupId"), + DeviceId = ReadString(el, "DeviceId") ?? string.Empty, Name = ReadString(el, "Name") ?? id!, + }); + } + + var tags = new List(); + foreach (var el in EnumerateArray(root, "Tags")) + { + var id = ReadString(el, "TagId"); + var deviceId = ReadString(el, "DeviceId"); + var name = ReadString(el, "Name"); + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue; + var accessLevel = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind == JsonValueKind.Number + ? (TagAccessLevel)alEl.GetInt32() : TagAccessLevel.Read; + var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String + ? tcEl.GetString() ?? "{}" : "{}"; + tags.Add(new Tag + { + TagId = id!, DeviceId = deviceId!, TagGroupId = ReadNullableString(el, "TagGroupId"), + Name = name!, DataType = ReadString(el, "DataType") ?? "Float", + AccessLevel = accessLevel, TagConfig = tagConfig, + }); + } + + var unsTagReferences = new List(); + foreach (var el in EnumerateArray(root, "UnsTagReferences")) + { + var id = ReadString(el, "UnsTagReferenceId"); + var equipmentId = ReadString(el, "EquipmentId"); + var tagId = ReadString(el, "TagId"); + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId)) continue; + unsTagReferences.Add(new UnsTagReference + { + UnsTagReferenceId = id!, EquipmentId = equipmentId!, TagId = tagId!, + DisplayNameOverride = ReadNullableString(el, "DisplayNameOverride"), + }); + } + + var unsAreas = new List(); + foreach (var el in EnumerateArray(root, "UnsAreas")) + { + var id = ReadString(el, "UnsAreaId"); + if (string.IsNullOrWhiteSpace(id)) continue; + unsAreas.Add(new UnsArea { UnsAreaId = id!, Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty }); + } + + var unsLines = new List(); + foreach (var el in EnumerateArray(root, "UnsLines")) + { + var id = ReadString(el, "UnsLineId"); + if (string.IsNullOrWhiteSpace(id)) continue; + unsLines.Add(new UnsLine { UnsLineId = id!, UnsAreaId = ReadString(el, "UnsAreaId") ?? string.Empty, Name = ReadString(el, "Name") ?? id! }); + } + + var equipment = new List(); + foreach (var el in EnumerateArray(root, "Equipment")) + { + var id = ReadString(el, "EquipmentId"); + if (string.IsNullOrWhiteSpace(id)) continue; + equipment.Add(new Equipment + { + EquipmentId = id!, UnsLineId = ReadString(el, "UnsLineId") ?? string.Empty, + Name = ReadString(el, "Name") ?? id!, + // MachineCode is a required member but is not used by the raw/UNS compose path (it drives no + // NodeId); read it when present, else a placeholder so the object initializer is satisfied. + MachineCode = ReadString(el, "MachineCode") ?? id!, + }); + } + + var composition = AddressSpaceComposer.Compose( + unsAreas, unsLines, equipment, driverInstances, Array.Empty(), + unsTagReferences: unsTagReferences, tags: tags, + rawFolders: rawFolders, devices: devices, tagGroups: tagGroups); + return (composition.RawContainers, composition.RawTags, composition.UnsReferenceVariables); + } + /// Build the scriptId → SourceCode map from the artifact's Scripts array (mirrors /// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts. private static Dictionary BuildScriptSourceMap(JsonElement root) @@ -482,11 +619,21 @@ public static class DeploymentArtifact var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip); var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip); + // v3 Batch 4 — the Raw device subtree + UNS reference variables. Built by reconstructing the raw/UNS + // entities from the artifact JSON and driving the SAME pure AddressSpaceComposer the live-edit side + // uses, so the artifact-decode seam is BYTE-PARITY with the composer (identical RawPaths, identical + // UNS NodeIds, identical Organizes backing paths). Only the three dual-namespace lists are taken from + // the composer result; the (already byte-parity) folder/vtag/alarm plans above are kept as-is. + var (rawContainers, rawTags, unsReferenceVariables) = BuildRawAndUnsSubtrees(root); + return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms) { EquipmentTags = equipmentTags, EquipmentVirtualTags = equipmentVirtualTags, EquipmentScriptedAlarms = equipmentScriptedAlarms, + RawContainers = rawContainers, + RawTags = rawTags, + UnsReferenceVariables = unsReferenceVariables, }; } catch (JsonException) @@ -548,6 +695,13 @@ public static class DeploymentArtifact EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(), EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(), EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(), + // v3 Batch 4: raw tags scope by their owning driver's cluster; UNS references by their equipment's + // cluster. Raw CONTAINER nodes carry no driver id, so they are kept in full — an out-of-cluster + // folder/driver/device/group node materialises only as an empty browse folder (harmless), while every + // raw VALUE + UNS reference node is correctly cluster-scoped. + RawContainers = full.RawContainers, + RawTags = full.RawTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(), + UnsReferenceVariables = full.UnsReferenceVariables.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(), }; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs index aa5db4e7..a31b1a85 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.OpcUaServer; @@ -36,8 +37,12 @@ public static class DiscoveredNodeMapper /// /// The folders, variables, and routing map to apply against the OPC UA address space. public static DiscoveredInjectionPlan Map( - string equipmentId, IReadOnlyList nodes, IReadOnlySet authoredRefs) + string rootNodeId, IReadOnlyList nodes, IReadOnlySet authoredRefs) { + // v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is + // // (slash-joined RawPath), NOT the retired equipment-scoped + // {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath). + static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail; var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList(); // Device-folder collapse: when every kept node shares one identical index-1 segment (the single @@ -64,20 +69,20 @@ public static class DiscoveredNodeMapper for (var i = 0; i < segs.Count; i++) { var folderPath = string.Join('/', segs.Take(i + 1)); - var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath); + var nodeId = Combine(rootNodeId, folderPath); if (folders.ContainsKey(nodeId)) continue; - var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i))); + var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i))); folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]); } var varFolderPath = string.Join('/', segs); - var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName); - // Mirror AddressSpaceApplier.MaterialiseEquipmentTags: a folder-less variable parents directly - // at the equipment (SubFolder("", ...) would yield a trailing-slash "EQ-1/" that mismatches the - // EquipmentNodeIds.Variable NodeId, which guards IsNullOrWhiteSpace). + var varNodeId = string.IsNullOrEmpty(varFolderPath) + ? Combine(rootNodeId, n.BrowseName) + : Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName); + // A folder-less variable parents directly at the root device node. var varParent = string.IsNullOrEmpty(varFolderPath) - ? equipmentId - : EquipmentNodeIds.SubFolder(equipmentId, varFolderPath); + ? rootNodeId + : Combine(rootNodeId, varFolderPath); variables.Add(new DiscoveredVariable( varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim)); routing[n.FullReference] = varNodeId; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 74d0af62..46e99fbf 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -111,34 +111,43 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // spawn so a restart's respawn never collides with the still-terminating old child. private long _childSpawnGeneration; - /// - /// Driver live-value routing map: (DriverInstanceId, FullName) → folder-scoped equipment - /// NodeId(s). Rebuilt every apply by from the - /// composition's EquipmentTags (mirroring VirtualTagHostActor._nodeIdByVtag), and - /// resolved in so a driver value published by wire-ref FullName lands - /// on the variable's actual folder-scoped NodeId. A set because the same driver ref can back - /// several equipment variables (e.g. identical machines sharing a register), and the per-apply - /// rebuild dedups by NodeId. - /// - private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet> _nodeIdByDriverRef = new(); + /// A materialised NodeId together with the v3 it lives in, so the + /// driver value fan-out posts each update to the sink with the right namespace. The same driver ref fans + /// to its raw node () and to every referencing UNS node + /// (). + private readonly record struct NodeRealmRef(string NodeId, AddressSpaceRealm Realm); /// - /// Inverse of : folder-scoped equipment NodeId → - /// (DriverInstanceId, FullName). Rebuilt every apply by - /// from the same EquipmentTags pass, and resolved by so an - /// inbound operator write targeting an equipment variable's NodeId is forwarded to the owning - /// driver child as a write of its wire-ref FullName. Each NodeId maps to exactly one driver - /// ref (a variable is backed by a single driver attribute), so this is a flat 1:1 map (the forward - /// map fans out 1:N because one ref can back several variables). + /// Driver live-value routing map: (DriverInstanceId, RawPath) → materialised NodeId(s) (realm-tagged). + /// Rebuilt every apply by from the composition's RawTags + /// ∪ UnsReferenceVariables, and resolved in so a driver value + /// published by wire-ref RawPath fans to its raw node AND every referencing UNS node with identical + /// value/quality/timestamp — the single-source-fan-out (no independent buffer, so no drift). A set + /// because one driver ref can back the raw node plus N UNS references; the per-apply rebuild dedups. + /// + private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet> _nodeIdByDriverRef = new(); + + /// + /// Inverse of : materialised value NodeId (BARE s= id) → + /// (DriverInstanceId, RawPath). v3 Batch 4: keyed by BOTH the raw NodeId AND every referencing UNS + /// NodeId (all mapping to the same driver ref — the single value source). Rebuilt every apply by + /// from the composition's RawTags ∪ + /// UnsReferenceVariables, and resolved by so an inbound + /// operator write to EITHER NodeId is forwarded to the owning driver child as a write of its wire-ref + /// RawPath. Keyed by the BARE id: the node manager's write hook passes the full ns-qualified id + /// (node.NodeId.ToString()); normalises it to the bare id + /// before lookup, so a raw write and a UNS write both resolve to the same driver ref (they share it — + /// a UNS reference's backing RawPath IS the raw node's, so no cross-realm ambiguity is possible). /// private readonly Dictionary _driverRefByNodeId = new(StringComparer.Ordinal); - /// (DriverInstanceId, FullName = alarm ConditionId / AlarmFullReference) → folder-scoped condition NodeId(s). - /// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves a native - /// alarm transition to the materialised Part 9 condition node(s). Alarm tags are conditions, not - /// value variables, so they are kept OUT of the value maps + value-subscription set. - private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet> _alarmNodeIdByDriverRef = new(); + /// (DriverInstanceId, RawPath = alarm ConditionId) → materialised condition NodeId(s) (realm-tagged). + /// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); WP4 adds the referencing + /// equipment notifier NodeIds. Resolves a native alarm transition to the materialised Part 9 condition + /// node(s). Alarm tags are conditions, not value variables, so they are kept OUT of the value maps + + /// value-subscription set. + private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet> _alarmNodeIdByDriverRef = new(); /// /// Inverse of : folder-scoped condition NodeId → @@ -620,13 +629,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // equipment variables (identical machines sharing a register), hence the fan-out. if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds)) { - foreach (var nodeId in nodeIds) + // v3 Batch 4 single-source fan-out: one driver publish for a RawPath lands on the raw NodeId AND + // every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/quality/timestamp. + foreach (var n in nodeIds) _opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate( - nodeId, msg.Value, msg.Quality, msg.TimestampUtc)); + n.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, n.Realm)); } else { - _log.Debug("DriverHost {Node}: no equipment-tag NodeId for ({Driver},{Ref}) — value dropped", + _log.Debug("DriverHost {Node}: no bound NodeId for ({Driver},{Ref}) — value dropped", _localNode, msg.DriverInstanceId, msg.FullReference); } } @@ -918,8 +929,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers { var key = (driverId, driverRef); if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) - _nodeIdByDriverRef[key] = set = new HashSet(StringComparer.Ordinal); - set.Add(nodeId); + _nodeIdByDriverRef[key] = set = new HashSet(); + // v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route + // through the Raw realm. + set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw)); _driverRefByNodeId[nodeId] = key; } _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes( @@ -1005,11 +1018,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers _localNode, msg.DriverInstanceId, msg.Args.ConditionId); } - foreach (var nodeId in nodeIds) + foreach (var n in nodeIds) { + var nodeId = n.NodeId; var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args); _opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate( - nodeId, snapshot, msg.Args.SourceTimestampUtc)); + nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm)); // Only the Primary publishes the cluster-wide alerts transition (see the gate above). if (!serviceAlertsAsPrimary) continue; @@ -1087,7 +1101,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers return; } - if (!_driverRefByNodeId.TryGetValue(msg.NodeId, out var target)) + // v3 Batch 4: the node manager's write hook passes the FULL ns-qualified NodeId string + // (node.NodeId.ToString(), e.g. "ns=3;s="). The routing map is keyed by the BARE s= identifier + // (the raw RawPath or the UNS path). Normalise before lookup so a write to EITHER a raw NodeId or a + // referencing UNS NodeId resolves to the SAME driver ref (they share it — a UNS reference's backing + // RawPath IS the raw node's, so there is no cross-realm ambiguity). + var bareNodeId = BareNodeId(msg.NodeId); + if (!_driverRefByNodeId.TryGetValue(bareNodeId, out var target)) { Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId}")); return; @@ -1115,6 +1135,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers .PipeTo(replyTo); } + /// + /// Normalise an OPC UA NodeId string to its BARE s= identifier. The node manager's inbound-write + /// hook passes the full ns-qualified form (NodeId.ToString() == "ns=<N>;s=<id>"); + /// the routing maps are keyed by the bare id (the RawPath / UNS path). The SDK format always prefixes + /// "ns=N;s=" for a string identifier in a non-zero namespace, so the first ";s=" is the + /// delimiter (an id may itself contain ";s=" later — IndexOf takes the FIRST, which is the + /// namespace delimiter). A bare id (no prefix) passes through unchanged. + /// + /// The (possibly ns-qualified) NodeId string. + /// The bare s= identifier. + internal static string BareNodeId(string nodeId) + { + if (string.IsNullOrEmpty(nodeId)) return nodeId; + var i = nodeId.IndexOf(";s=", StringComparison.Ordinal); + if (i >= 0) return nodeId[(i + 3)..]; + if (nodeId.StartsWith("s=", StringComparison.Ordinal)) return nodeId[2..]; + return nodeId; + } + /// /// Routes an inbound native-condition acknowledge (the host Tells this from the OPC UA node-manager /// side when a client Acknowledges a NATIVE Part 9 condition) to the owning driver child. Mirrors @@ -1456,89 +1495,85 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers return; } - // Value-subscription set: alarm-bearing tags are Part 9 conditions, not value variables, so they - // are excluded — the driver must not value-subscribe an alarm attribute (it is fed via the native - // alarm event stream, routed by ForwardNativeAlarm). - var refsByDriver = composition.EquipmentTags + // v3 Batch 4: the driver subscribes + publishes by RawPath (the wire-ref == RawPath == RawTagPlan.NodeId + // per the v3 identity contract). Value-subscription set = the non-alarm raw tags' RawPaths; alarm-bearing + // raw tags are Part 9 conditions, fed via the native alarm event stream (ForwardNativeAlarm), so they are + // excluded from the value set. + var refsByDriver = composition.RawTags .Where(t => t.Alarm is null) .GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal) .ToDictionary( g => g.Key, - g => (IReadOnlyList)g.Select(t => t.FullName) + g => (IReadOnlyList)g.Select(t => t.NodeId) .Distinct(StringComparer.Ordinal) .ToArray(), StringComparer.Ordinal); - // Native-alarm subscription set: the alarm-bearing tags' FullNames (= the driver's - // ConditionId/AlarmFullReference). An IAlarmSource driver suppresses OnAlarmEvent until at least one - // alarm subscription exists (e.g. GalaxyDriver gates its central feed on _alarmSubscriptions), so the - // instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by - // ConditionId in ForwardNativeAlarm; this set just opens (and scopes) the subscription. - var alarmRefsByDriver = composition.EquipmentTags + // Native-alarm subscription set: the alarm-bearing raw tags' RawPaths (= the driver's ConditionId). + // An IAlarmSource driver suppresses OnAlarmEvent until at least one alarm subscription exists, so the + // instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by ConditionId + // in ForwardNativeAlarm; this set just opens (and scopes) the subscription. + var alarmRefsByDriver = composition.RawTags .Where(t => t.Alarm is not null) .GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal) .ToDictionary( g => g.Key, - g => (IReadOnlyList)g.Select(t => t.FullName) + g => (IReadOnlyList)g.Select(t => t.NodeId) .Distinct(StringComparer.Ordinal) .ToArray(), StringComparer.Ordinal); - // Rebuild the driver live-value routing map from the SAME EquipmentTags pass (mirrors - // VirtualTagHostActor._nodeIdByVtag): map each tag's (DriverInstanceId, FullName) wire-ref to - // the folder-scoped equipment NodeId the materialiser placed its variable at, so ForwardToMux - // can land driver values on the right node. Clear-and-repopulate every apply so renames - // (Name/FolderPath/EquipmentId changes) and removals are reflected. + // Referencing UNS nodes by backing RawPath — each raw value tag fans out to its own raw NodeId PLUS + // every UNS reference that projects it (dual-NodeId registration against the SAME driver ref). + var unsRefsByRawPath = new Dictionary>(StringComparer.Ordinal); + foreach (var v in composition.UnsReferenceVariables) + { + if (!unsRefsByRawPath.TryGetValue(v.BackingRawPath, out var list)) + unsRefsByRawPath[v.BackingRawPath] = list = new List(); + list.Add(v); + } + + // Rebuild the driver live-value + write routing maps from the RawTags ∪ UnsReferenceVariables pass. + // Clear-and-repopulate every apply so renames/removals/re-points are reflected. _nodeIdByDriverRef.Clear(); - // Inverse map for the inbound operator-write path (NodeId → (DriverInstanceId, FullName)): an - // operator writes to the variable's folder-scoped NodeId, but the driver writes by its wire-ref - // FullName. Cleared + repopulated from the SAME EquipmentTags pass so renames/removals are - // reflected. Each NodeId maps to exactly one driver ref (a variable is backed by a single driver - // attribute), so last-writer-wins on the rare duplicate is harmless. _driverRefByNodeId.Clear(); - // Alarm condition routing map: (DriverInstanceId, FullName = alarm ConditionId/AlarmFullReference) → folder-scoped - // condition NodeId(s). Built from the SAME EquipmentTags pass (alarm-bearing tags only) so - // ForwardNativeAlarm can land a native transition on the right condition node. Clear-and-rebuild - // every apply; the projector is Clear()'d too so stale per-condition state never leaks across - // redeploys (renames/removals/address-space rebuilds). _alarmNodeIdByDriverRef.Clear(); - // Inverse alarm map for the inbound native-condition ack path (condition NodeId → (DriverInstanceId, - // FullName)): an OPC UA client acknowledges the condition's folder-scoped NodeId, but the driver - // acknowledges by its wire-ref FullName (= ConditionId). Cleared + repopulated from the SAME - // alarm-bearing EquipmentTags pass so renames/removals are reflected. Each condition NodeId maps to - // exactly one driver ref (a condition is backed by a single driver alarm), so last-writer-wins on the - // rare duplicate is harmless. _driverRefByAlarmNodeId.Clear(); - // Per-condition metadata (EquipmentId / Name / OPC UA alarm type) for the alerts fan-out, built in - // the SAME alarm branch as the node map so a redeploy can't leave it out of sync. Cleared alongside it. _alarmMetaByNodeId.Clear(); _nativeAlarmProjector.Clear(); - foreach (var t in composition.EquipmentTags) + foreach (var t in composition.RawTags) { - var key = (t.DriverInstanceId, t.FullName); - var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name); + var key = (t.DriverInstanceId, t.NodeId); // (DriverInstanceId, RawPath) — RawPath IS the wire-ref if (t.Alarm is not null) { - // Alarm tags are conditions, not value variables: route them ONLY into the alarm map and - // keep them OUT of the value maps + value-subscription set (so they don't get both a value - // variable AND a condition). + // Native alarm → a single Part 9 condition at the RawPath (Raw realm). WP4 adds the referencing + // equipment notifier NodeIds; WP3 wires the single raw condition. if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset)) - _alarmNodeIdByDriverRef[key] = aset = new HashSet(StringComparer.Ordinal); - aset.Add(nodeId); - // Inverse 1:1 map for the inbound native-condition ack path: the materialised condition - // NodeId routes back to the owning (DriverInstanceId, FullName=ConditionId) so an OPC UA - // acknowledge of this condition reaches the right driver child. - _driverRefByAlarmNodeId[nodeId] = key; - // Capture the per-condition metadata the alerts fan-out (ForwardNativeAlarm) needs to build - // the AlarmTransitionEvent: the equipment path, the operator-visible alarm name, and the - // OPC UA Part 9 subtype. Keyed by the condition NodeId (the projection's own key). - _alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva); + _alarmNodeIdByDriverRef[key] = aset = new HashSet(); + aset.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw)); + // Inverse 1:1 map for the inbound native-condition ack path (keyed by BARE condition NodeId). + _driverRefByAlarmNodeId[t.NodeId] = key; + // Per-condition metadata for the alerts fan-out. WP4 refines /alerts identity to the referencing + // equipment paths; WP3 keys the display off the RawPath. + _alarmMetaByNodeId[t.NodeId] = (t.NodeId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva); continue; } + + // Value tag: register the RAW NodeId (Raw realm) AND every referencing UNS NodeId (Uns realm) + // against the SAME driver ref — the single value source fans to all of them. if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) - _nodeIdByDriverRef[key] = set = new HashSet(StringComparer.Ordinal); - set.Add(nodeId); - _driverRefByNodeId[nodeId] = key; + _nodeIdByDriverRef[key] = set = new HashSet(); + set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw)); + _driverRefByNodeId[t.NodeId] = key; + + if (unsRefsByRawPath.TryGetValue(t.NodeId, out var refs)) + { + foreach (var v in refs) + { + set.Add(new NodeRealmRef(v.NodeId, AddressSpaceRealm.Uns)); + _driverRefByNodeId[v.NodeId] = key; // a UNS write resolves to the same driver ref + } + } } // Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index f8c856c0..b49b623e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -39,16 +39,25 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// for its cached status. Private singleton — the actor pumps this for itself, no external sender. private sealed class HealthTick { public static readonly HealthTick Instance = new(); private HealthTick() { } } - public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc); + /// A driver/vtag value update for a single materialised Variable node. v3 Batch 4: carries the + /// node's so the sink resolves the right namespace — the driver fan-out + /// posts one of these per registered NodeId (the raw node in and each + /// referencing UNS node in ) with identical value/quality/timestamp. + /// defaults to so pre-v3 callers (VirtualTag + /// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node. + public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns); /// Carries the full Part 9 condition state for a scripted alarm to the sink. The /// snapshot is the Commons projection the Runtime host maps from the engine's /// Core AlarmConditionState + severity/message — the actor stays decoupled from /// Core.ScriptedAlarms. - /// The alarm node id (== ScriptedAlarmId for materialised conditions). + /// The alarm node id (== ScriptedAlarmId for scripted conditions; == RawPath for + /// v3 native raw conditions). /// The full condition state to project onto the node. /// The source timestamp of the transition in UTC. - public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc); + /// The namespace realm the condition lives in — for + /// scripted alarms (default), for v3 native raw conditions. + public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns); /// /// Triggers an address-space rebuild. is the deployment /// just applied by the host; the rebuild loads THAT artifact so materialisation matches the @@ -263,7 +272,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers { try { - _sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc); + _sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, msg.Realm); Interlocked.Increment(ref _writes); OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "value")); } @@ -277,7 +286,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers { try { - _sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc); + _sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm); Interlocked.Increment(ref _writes); OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "alarm")); } @@ -340,6 +349,14 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter. var failedNodes = outcome.FailedNodes; failedNodes += _applier.MaterialiseHierarchy(composition); + // v3 Batch 4 — the Raw device subtree (ns=Raw): containers (Folder→Driver→Device→TagGroup) then + // tag Variables keyed by RawPath. Materialised BEFORE the UNS references so each UNS Organizes→Raw + // edge finds its raw target. The driver binds live values to these raw NodeIds. + failedNodes += _applier.MaterialiseRawSubtree(composition); + // v3 Batch 4 — the UNS reference Variables (ns=UNS): each projects a raw tag under its equipment + // folder (created by MaterialiseHierarchy) with an Organizes→Raw edge; values fan out from the raw + // node. Runs AFTER both the equipment folders AND the raw subtree exist. + failedNodes += _applier.MaterialiseUnsReferences(composition); // T14 — scripted alarms get their own pass right after the hierarchy so the equipment // folders they parent under already exist. Materialises real Part 9 AlarmConditionState // nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs index 52f007fb..961f2fad 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs @@ -223,9 +223,13 @@ public sealed class VirtualTagHostActor : ReceiveActor } } - /// Folder-scoped NodeId for a VirtualTag plan. The formula now lives in the shared - /// (the single source of truth that AddressSpaceApplier also - /// materialises against), so the published value always lands on the NodeId that was materialised. + /// UNS-realm NodeId for a VirtualTag plan — the equipment-anchored slash path + /// {EquipmentId}[/{FolderPath}]/{Name}, expressed through the v3 identity authority + /// . Byte-identical to the applier's equipment-VirtualTag materialise + /// pass (its private UnsEquipmentVar), so the published value always lands on the materialised + /// NodeId. FolderPath is empty for VirtualTags today, but a multi-segment path is split into segments. private static string NodeIdFor(EquipmentVirtualTagPlan p) => - EquipmentNodeIds.Variable(p.EquipmentId, p.FolderPath, p.Name); + string.IsNullOrWhiteSpace(p.FolderPath) + ? V3NodeIds.Uns(p.EquipmentId, p.Name) + : V3NodeIds.Uns(new[] { p.EquipmentId }.Concat(p.FolderPath.Split('/')).Append(p.Name)); } 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 cdbb26b0..3d152e1c 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 @@ -19,7 +19,7 @@ public class DeferredAddressSpaceSinkTests { var sink = new DeferredAddressSpaceSink(); // Must not throw. - sink.WriteValue("ns=2;s=x", 42, OpcUaQuality.Good, DateTime.UtcNow); + sink.WriteValue("ns=2;s=x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); } [Fact] @@ -34,7 +34,7 @@ public class DeferredAddressSpaceSinkTests { var sink = new DeferredAddressSpaceSink(); sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null, - dataType: "Boolean", isArray: false, arrayLength: null).ShouldBeFalse(); + dataType: "Boolean", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse(); } // ---------- after SetSink — operations are forwarded ---------- @@ -46,7 +46,7 @@ public class DeferredAddressSpaceSinkTests var sink = new DeferredAddressSpaceSink(); sink.SetSink(inner); - sink.WriteValue("ns=2;s=x", 99, OpcUaQuality.Good, DateTime.UtcNow); + sink.WriteValue("ns=2;s=x", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); inner.WriteValueCalled.ShouldBeTrue(); } @@ -73,7 +73,7 @@ public class DeferredAddressSpaceSinkTests sink.SetSink(new SpySink()); sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null, - dataType: "Int32", isArray: false, arrayLength: null).ShouldBeFalse(); + dataType: "Int32", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse(); } [Fact] @@ -84,7 +84,7 @@ public class DeferredAddressSpaceSinkTests sink.SetSink(surgical); var result = sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null, - dataType: "Float", isArray: false, arrayLength: null); + dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns); result.ShouldBeTrue(); surgical.UpdateCalled.ShouldBeTrue(); @@ -97,7 +97,7 @@ public class DeferredAddressSpaceSinkTests var sink = new DeferredAddressSpaceSink(); sink.SetSink(new SpySink()); - sink.UpdateFolderDisplayName("area-1", "Plant South").ShouldBeFalse(); + sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns).ShouldBeFalse(); } [Fact] @@ -108,7 +108,7 @@ public class DeferredAddressSpaceSinkTests var sink = new DeferredAddressSpaceSink(); sink.SetSink(surgical); - var result = sink.UpdateFolderDisplayName("area-1", "Plant South"); + var result = sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns); result.ShouldBeTrue(); surgical.FolderRenameCalled.ShouldBeTrue(); @@ -122,9 +122,9 @@ public class DeferredAddressSpaceSinkTests var sink = new DeferredAddressSpaceSink(); sink.SetSink(new SpySink()); - sink.RemoveVariableNode("v-1").ShouldBeFalse(); - sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse(); - sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse(); + sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse(); + sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse(); + sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse(); } [Fact] @@ -134,9 +134,9 @@ public class DeferredAddressSpaceSinkTests var sink = new DeferredAddressSpaceSink(); sink.SetSink(surgical); - sink.RemoveVariableNode("v-1").ShouldBeTrue(); - sink.RemoveAlarmConditionNode("a-1").ShouldBeTrue(); - sink.RemoveEquipmentSubtree("eq-1").ShouldBeTrue(); + sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeTrue(); + sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeTrue(); + sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue(); surgical.RemoveVariableCalled.ShouldBeTrue(); surgical.RemoveAlarmCalled.ShouldBeTrue(); @@ -147,9 +147,9 @@ public class DeferredAddressSpaceSinkTests public void Before_SetSink_remove_members_return_false() { var sink = new DeferredAddressSpaceSink(); - sink.RemoveVariableNode("v-1").ShouldBeFalse(); - sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse(); - sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse(); + sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse(); + sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse(); + sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse(); } // ---------- SetSink(null) reverts to null sink ---------- @@ -163,7 +163,7 @@ public class DeferredAddressSpaceSinkTests sink.SetSink(null); // revert sink.UpdateTagAttributes("ns=2;s=x", writable: false, historianTagname: null, - dataType: "Boolean", isArray: false, arrayLength: null).ShouldBeFalse(); + dataType: "Boolean", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse(); } [Fact] @@ -174,7 +174,7 @@ public class DeferredAddressSpaceSinkTests sink.SetSink(inner); sink.SetSink(null); // revert - sink.WriteValue("ns=2;s=x", 1, OpcUaQuality.Good, DateTime.UtcNow); + sink.WriteValue("ns=2;s=x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); inner.WriteValueCalled.ShouldBeFalse("write should be no-op after reverting to null sink"); } @@ -190,9 +190,9 @@ public class DeferredAddressSpaceSinkTests => WriteValueCalled = true; public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + 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 AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } @@ -205,9 +205,9 @@ public class DeferredAddressSpaceSinkTests public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + 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 AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/EquipmentNodeIdsTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/EquipmentNodeIdsTests.cs deleted file mode 100644 index 906ac5d2..00000000 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/EquipmentNodeIdsTests.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; - -namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa; - -public class EquipmentNodeIdsTests -{ - [Fact] - public void Variable_with_no_folder_is_equipment_slash_name() - => EquipmentNodeIds.Variable("eq-1", "", "speed").ShouldBe("eq-1/speed"); - - [Fact] - public void Variable_with_null_folder_is_equipment_slash_name() - => EquipmentNodeIds.Variable("eq-1", null, "speed").ShouldBe("eq-1/speed"); - - [Fact] - public void Variable_with_folder_is_equipment_slash_folder_slash_name() - => EquipmentNodeIds.Variable("eq-1", "registers", "speed").ShouldBe("eq-1/registers/speed"); - - [Fact] - public void Variable_with_whitespace_folder_is_equipment_slash_name() - => EquipmentNodeIds.Variable("eq-1", " ", "speed").ShouldBe("eq-1/speed"); - - [Fact] - public void SubFolder_is_equipment_slash_folder() - => EquipmentNodeIds.SubFolder("eq-1", "registers").ShouldBe("eq-1/registers"); -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs index 979b33f6..02b8236d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Xunit; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; @@ -65,8 +66,8 @@ public sealed class SubscriptionSurvivalTests new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null)); applier.MaterialiseHierarchy(withA); applier.MaterialiseEquipmentTags(withA); - var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A"); - sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); + var nodeIdAString = V3NodeIds.Uns("eq-1", "A"); + sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); // --- Open a client session + subscription + monitored item on A. --- using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); @@ -115,7 +116,7 @@ public sealed class SubscriptionSurvivalTests // --- A's monitored item must still be alive: a new write to A is delivered. --- lock (gate) received.Clear(); - sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); + sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5)); lock (gate) @@ -125,8 +126,8 @@ public sealed class SubscriptionSurvivalTests } // --- B is browsable + readable (the add landed). --- - var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns); - sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); + var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns); + sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); var bValue = await session.ReadValueAsync(nodeIdB, ct); bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown); bValue.Value.ShouldBe(7); @@ -173,10 +174,10 @@ public sealed class SubscriptionSurvivalTests new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); applier.MaterialiseHierarchy(withAB); applier.MaterialiseEquipmentTags(withAB); - var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A"); - var nodeIdBString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"); - sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); - sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); + var nodeIdAString = V3NodeIds.Uns("eq-1", "A"); + var nodeIdBString = V3NodeIds.Uns("eq-1", "B"); + sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); @@ -212,7 +213,7 @@ public sealed class SubscriptionSurvivalTests await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5)); // A stays alive: a fresh write to A is delivered. - sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); + sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5)); // B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path @@ -261,8 +262,8 @@ public sealed class SubscriptionSurvivalTests new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); applier.MaterialiseHierarchy(withAB); applier.MaterialiseEquipmentTags(withAB); - var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A"); - sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); + var nodeIdAString = V3NodeIds.Uns("eq-1", "A"); + sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); @@ -295,18 +296,18 @@ public sealed class SubscriptionSurvivalTests applier.AnnounceAddedNodes(plan); // A stays alive. - sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); + sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5)); // B is gone. (1.5.378 node-cache read throws for an unknown node — see above.) - var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns); + var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns); var bReadEx = await Should.ThrowAsync( async () => await session.ReadValueAsync(nodeIdB, ct)); bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown); // C is browsable + readable. - var nodeIdC = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "C"), ns); - sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow); + var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns); + sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); var cRead = await session.ReadValueAsync(nodeIdC, ct); cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown); cRead.Value.ShouldBe(9); 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 6e5aec88..b3ceb8f6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -168,7 +168,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault"); } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { if (ThrowOnMaterialiseAlarm) throw new InvalidOperationException("simulated MaterialiseAlarmCondition fault"); } @@ -178,7 +178,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault"); } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault"); } 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 214da200..5c6edd6e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs @@ -319,7 +319,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } /// Records a folder creation request. /// The node ID of the folder. /// The node ID of the parent folder, or null for root. @@ -333,7 +333,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } /// Rebuilds the address space (stub implementation for testing). public void RebuildAddressSpace() { } /// Announces a NodeAdded model-change (stub implementation for testing). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs index f8edaa56..9e6141a3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs @@ -292,8 +292,8 @@ public sealed class AddressSpaceApplierProvisioningTests RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()) { - RemovedEquipmentTags = new[] { removedTag }, - ChangedEquipmentTags = new[] { new AddressSpacePlan.EquipmentTagDelta(prev, cur) }, + RemovedRawTags = new[] { removedTag }, + ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) }, }; applier.Apply(plan); @@ -324,15 +324,20 @@ public sealed class AddressSpaceApplierProvisioningTests outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned } - private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref") - => new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName, - Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName); + // v3 Batch 4: historized value tags are RAW tags — the mux ref + historian default are the RawPath + // (== NodeId). The `fullName` param plays the role of the RawPath here (kept as a param name so the + // existing assertions — HistorizedTagRef("ref"/"40001", …) — read unchanged). + private static RawTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref") + => new("tag-" + displayName, NodeId: fullName, ParentNodeId: null, DriverInstanceId: "drv", Name: displayName, + DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty(), + IsHistorized: true, HistorianTagname: historianName); - private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType) - => new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref", - Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null); + private static RawTagPlan NonHistorizedTag(string displayName, string dataType) + => new("tag-" + displayName, NodeId: "ref", ParentNodeId: null, DriverInstanceId: "drv", Name: displayName, + DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty(), + IsHistorized: false, HistorianTagname: null); - private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new( + private static AddressSpacePlan PlanWithAddedTags(params RawTagPlan[] tags) => new( AddedEquipment: Array.Empty(), RemovedEquipment: Array.Empty(), ChangedEquipment: Array.Empty(), @@ -343,6 +348,6 @@ public sealed class AddressSpaceApplierProvisioningTests RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()) { - AddedEquipmentTags = tags, + AddedRawTags = tags, }; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs new file mode 100644 index 00000000..392ea476 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs @@ -0,0 +1,177 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// v3 Batch 4 (B4-WP3) — the applier's dual-realm materialise passes. +/// lights up the ns=Raw device tree +/// (containers as folders + tags as variables, each keyed by its RawPath, all in +/// ); lights +/// up the ns=UNS reference variables (each under its equipment folder in +/// ) and wires an Organizes reference UNS→Raw. A historized raw +/// tag's UNS reference inherits the SAME historian tagname (both NodeIds → one tagname). Every call carries +/// its realm EXPLICITLY. +/// +public sealed class AddressSpaceApplierRawUnsTests +{ + private static AddressSpaceApplier NewApplier(RealmRecordingSink sink) => + new(sink, NullLogger.Instance); + + [Fact] + public void MaterialiseRawSubtree_creates_raw_containers_and_tags_in_raw_realm() + { + var sink = new RealmRecordingSink(); + var composition = new AddressSpaceComposition( + Array.Empty(), Array.Empty(), Array.Empty()) + { + RawContainers = new[] + { + new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder), + new RawContainerNode("Plant/Modbus", "Plant", "Modbus", RawNodeKind.Driver), + new RawContainerNode("Plant/Modbus/dev1", "Plant/Modbus", "dev1", RawNodeKind.Device), + }, + RawTags = new[] + { + new RawTagPlan("t1", "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1", "drv-1", "speed", + "Float", Writable: true, Alarm: null, ReferencingEquipmentPaths: Array.Empty()), + }, + }; + + NewApplier(sink).MaterialiseRawSubtree(composition); + + // Containers are folders in the Raw realm, parents-before-children. + sink.Folders.Select(f => (f.NodeId, f.Realm)).ShouldBe(new[] + { + ("Plant", AddressSpaceRealm.Raw), + ("Plant/Modbus", AddressSpaceRealm.Raw), + ("Plant/Modbus/dev1", AddressSpaceRealm.Raw), + }); + // The tag is a variable keyed by its RawPath, in the Raw realm, parented under its group/device. + var v = sink.Variables.ShouldHaveSingleItem(); + v.NodeId.ShouldBe("Plant/Modbus/dev1/speed"); + v.Parent.ShouldBe("Plant/Modbus/dev1"); + v.Realm.ShouldBe(AddressSpaceRealm.Raw); + v.Writable.ShouldBeTrue(); + v.HistorianTagname.ShouldBeNull(); + } + + [Fact] + public void MaterialiseRawSubtree_resolves_historian_tagname_default_and_override() + { + var sink = new RealmRecordingSink(); + var composition = new AddressSpaceComposition( + Array.Empty(), Array.Empty(), Array.Empty()) + { + RawTags = new[] + { + // Historized, no override → historian tagname defaults to the RawPath. + new RawTagPlan("t1", "Plant/A/dev/def", "Plant/A/dev", "drv", "def", "Float", + Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty(), IsHistorized: true), + // Historized with override → the override wins. + new RawTagPlan("t2", "Plant/A/dev/ovr", "Plant/A/dev", "drv", "ovr", "Float", + Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty(), IsHistorized: true, HistorianTagname: "WW.Override"), + }, + }; + + NewApplier(sink).MaterialiseRawSubtree(composition); + + var byId = sink.Variables.ToDictionary(v => v.NodeId); + byId["Plant/A/dev/def"].HistorianTagname.ShouldBe("Plant/A/dev/def"); // default = RawPath + byId["Plant/A/dev/ovr"].HistorianTagname.ShouldBe("WW.Override"); + } + + [Fact] + public void MaterialiseRawSubtree_alarm_tag_materialises_a_raw_condition_not_a_variable() + { + var sink = new RealmRecordingSink(); + var composition = new AddressSpaceComposition( + Array.Empty(), 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), + ReferencingEquipmentPaths: Array.Empty()), + }, + }; + + NewApplier(sink).MaterialiseRawSubtree(composition); + + sink.Variables.ShouldBeEmpty(); // an alarm tag is a condition, not a value variable + var c = sink.Conditions.ShouldHaveSingleItem(); + c.NodeId.ShouldBe("Plant/A/dev/OverTemp"); // ConditionId == RawPath + c.Parent.ShouldBe("Plant/A/dev"); + c.Realm.ShouldBe(AddressSpaceRealm.Raw); + c.IsNative.ShouldBeTrue(); + } + + [Fact] + public void MaterialiseUnsReferences_creates_uns_variable_and_organizes_edge_inheriting_historian() + { + var sink = new RealmRecordingSink(); + var composition = new AddressSpaceComposition( + Array.Empty(), Array.Empty(), Array.Empty()) + { + RawTags = new[] + { + // Backing raw tag: historized with an override → the UNS ref must register the SAME tagname. + new RawTagPlan("t1", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float", + Writable: true, Alarm: null, ReferencingEquipmentPaths: new[] { "filling/line1/station1" }, + IsHistorized: true, HistorianTagname: "WW.Speed"), + }, + UnsReferenceVariables = new[] + { + new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed", + "Plant/A/dev/speed", "Float", Writable: true), + }, + }; + + NewApplier(sink).MaterialiseUnsReferences(composition); + + // The UNS reference is a variable in the Uns realm, parented under its equipment folder (EquipmentId). + var v = sink.Variables.ShouldHaveSingleItem(); + v.NodeId.ShouldBe("filling/line1/station1/speed"); + v.Parent.ShouldBe("EQ-1"); + v.Realm.ShouldBe(AddressSpaceRealm.Uns); + v.Writable.ShouldBeTrue(); + v.HistorianTagname.ShouldBe("WW.Speed"); // inherited from the backing historized raw tag + + // An Organizes reference UNS→Raw links the two. + var r = sink.References.ShouldHaveSingleItem(); + r.Source.ShouldBe("filling/line1/station1/speed"); + r.SourceRealm.ShouldBe(AddressSpaceRealm.Uns); + r.Target.ShouldBe("Plant/A/dev/speed"); + r.TargetRealm.ShouldBe(AddressSpaceRealm.Raw); + r.ReferenceType.ShouldBe("Organizes"); + } + + /// A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges. + private sealed class RealmRecordingSink : IOpcUaAddressSpaceSink + { + public List<(string NodeId, string? Parent, AddressSpaceRealm Realm)> Folders { get; } = new(); + 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 void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) + => Folders.Add((folderNodeId, parentNodeId, realm)); + + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) + => Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm)); + + 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 AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") + => References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType)); + + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } + public void RebuildAddressSpace() { } + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { } + } +} 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 1040e634..f42e497b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -148,7 +148,7 @@ public sealed class AddressSpaceApplierTests // A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite). sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true)); // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath). - sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); + sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); } /// Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment @@ -175,7 +175,7 @@ public sealed class AddressSpaceApplierTests // A Read plan threads Writable: false (the node stays CurrentRead). sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false)); // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath). - sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp")); + sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp")); } /// Regression for the FullName-as-NodeId collision: two identical machines exposing the @@ -228,13 +228,13 @@ public sealed class AddressSpaceApplierTests applier.MaterialiseEquipmentTags(composition); // The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition. - var plainNodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed"); + var plainNodeId = V3NodeIds.Uns("eq-1", "Speed"); sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true)); sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId); // The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent, // matching display/type/severity) and did NOT drive EnsureVariable. - var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp"); + var alarmNodeId = V3NodeIds.Uns("eq-1", "OverTemp"); // A native equipment-tag alarm: the call-site threads isNative: true. sink.AlarmConditionCalls.ShouldHaveSingleItem() .ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true)); @@ -264,7 +264,7 @@ public sealed class AddressSpaceApplierTests // The sub-folder is still created for an alarm tag with a FolderPath. sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); // Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable. - var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "Diagnostics", "OverTemp"); + var alarmNodeId = V3NodeIds.Uns("eq-1", "Diagnostics", "OverTemp"); // A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true. sink.AlarmConditionCalls.ShouldHaveSingleItem() .ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true)); @@ -300,9 +300,9 @@ public sealed class AddressSpaceApplierTests applier.MaterialiseEquipmentTags(composition); var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname); - byNode[EquipmentNodeIds.Variable("eq-1", "", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName - byNode[EquipmentNodeIds.Variable("eq-1", "", "BOverride")].ShouldBe("WW.Override"); // override verbatim - byNode[EquipmentNodeIds.Variable("eq-1", "", "CPlain")].ShouldBeNull(); // not historized ⇒ null + byNode[V3NodeIds.Uns("eq-1", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName + byNode[V3NodeIds.Uns("eq-1", "BOverride")].ShouldBe("WW.Override"); // override verbatim + byNode[V3NodeIds.Uns("eq-1", "CPlain")].ShouldBeNull(); // not historized ⇒ null } /// Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to @@ -326,7 +326,7 @@ public sealed class AddressSpaceApplierTests applier.MaterialiseEquipmentTags(composition); var call = sink.HistorianCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); + call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); call.HistorianTagname.ShouldBe("40001"); } @@ -353,7 +353,7 @@ public sealed class AddressSpaceApplierTests applier.MaterialiseEquipmentTags(composition); var call = sink.ArrayCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Buffer")); + call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Buffer")); call.IsArray.ShouldBeTrue(); call.ArrayLength.ShouldBe(16u); } @@ -380,7 +380,7 @@ public sealed class AddressSpaceApplierTests applier.MaterialiseEquipmentTags(composition); var call = sink.ArrayCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); + call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); call.IsArray.ShouldBeFalse(); call.ArrayLength.ShouldBeNull(); } @@ -471,11 +471,11 @@ public sealed class AddressSpaceApplierTests // VirtualTags are computed outputs — always read-only (Writable: false). sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false)); // Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula. - sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "speed-rpm")); + sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "speed-rpm")); } /// Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and - /// the equipment-VirtualTag pass is byte-identical to — the + /// the equipment-VirtualTag pass is byte-identical to V3NodeIds.Uns — the /// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty /// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test /// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the @@ -507,10 +507,10 @@ public sealed class AddressSpaceApplierTests applier.MaterialiseEquipmentVirtualTags(composition); var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList(); - nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "", "Speed")); - nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp")); - nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "", "Efficiency")); - nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "Calc", "Avg")); + nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Speed")); + nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp")); + nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Efficiency")); + nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Calc", "Avg")); } /// Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables @@ -760,7 +760,7 @@ public sealed class AddressSpaceApplierTests outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); + call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); call.Writable.ShouldBeTrue(); } @@ -815,7 +815,7 @@ public sealed class AddressSpaceApplierTests outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); - var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed"); + var nodeId = V3NodeIds.Uns("eq-1", "Speed"); // Terminal Bad written to the removed variable BEFORE the removal. sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad)); sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId)); @@ -842,7 +842,7 @@ public sealed class AddressSpaceApplierTests var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); - var nodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp"); + var nodeId = V3NodeIds.Uns("eq-1", "OverTemp"); // Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node. var write = sink.AlarmWrites.ShouldHaveSingleItem(); write.NodeId.ShouldBe(nodeId); @@ -953,7 +953,7 @@ public sealed class AddressSpaceApplierTests outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); // The removed node is torn down in place; the add is left to the publish actor's materialise passes. - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", EquipmentNodeIds.Variable("eq-1", "", "Old"))); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Old"))); outcome.AddedNodes.ShouldBe(1); outcome.RemovedNodes.ShouldBe(1); } @@ -967,7 +967,7 @@ public sealed class AddressSpaceApplierTests var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Slot"); + var nodeId = V3NodeIds.Uns("eq-1", "Slot"); var plan = EmptyPlan with { // Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot). @@ -1457,8 +1457,8 @@ public sealed class AddressSpaceApplierTests sink.RebuildCalls.ShouldBe(0); outcome.RemovedNodes.ShouldBe(2); // both removals counted sink.RemoveCalls.Count.ShouldBe(2); - sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Speed"))); - sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Efficiency"))); + sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Speed"))); + sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency"))); } // ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) ----- @@ -1490,7 +1490,7 @@ public sealed class AddressSpaceApplierTests outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); + call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); call.Writable.ShouldBeTrue(); // the NEW Writable value call.Historian.ShouldBeNull(); // not historized outcome.ChangedNodes.ShouldBe(1); @@ -1584,7 +1584,7 @@ public sealed class AddressSpaceApplierTests outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); + call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); call.DataType.ShouldBe("Int32"); // the NEW DataType call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call call.IsArray.ShouldBeFalse(); @@ -1781,8 +1781,8 @@ public sealed class AddressSpaceApplierTests sink.SurgicalCalls.Count.ShouldBe(2); // Both expected node ids must appear (order is not guaranteed). - var nodeId1 = EquipmentNodeIds.Variable("eq-1", "", "Speed"); - var nodeId2 = EquipmentNodeIds.Variable("eq-1", "", "Pressure"); + var nodeId1 = V3NodeIds.Uns("eq-1", "Speed"); + var nodeId2 = V3NodeIds.Uns("eq-1", "Pressure"); sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable); sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable); @@ -2040,7 +2040,7 @@ public sealed class AddressSpaceApplierTests }, }; - applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { EquipmentNodeIds.SubFolder("eq-1", "Diag") }); + applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { V3NodeIds.Uns("eq-1", "Diag") }); } /// An added scripted alarm announces its equipment folder (where its condition node parents). @@ -2101,7 +2101,7 @@ public sealed class AddressSpaceApplierTests applier.AnnounceAddedNodes(plan); sink.ModelChangeCalls.OrderBy(x => x).ShouldBe( - new[] { "eq-1", EquipmentNodeIds.SubFolder("eq-1", "Diag"), "line-7" }.OrderBy(x => x)); + new[] { "eq-1", V3NodeIds.Uns("eq-1", "Diag"), "line-7" }.OrderBy(x => x)); } private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) => @@ -2276,7 +2276,7 @@ public sealed class AddressSpaceApplierTests /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) => AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative)); /// Records a folder creation call. /// The folder node ID. @@ -2291,7 +2291,7 @@ public sealed class AddressSpaceApplierTests /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); HistorianQueue.Enqueue((variableNodeId, historianTagname)); @@ -2323,11 +2323,11 @@ public sealed class AddressSpaceApplierTests /// No-op alarm condition write call. public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op alarm-condition materialise call. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } /// No-op folder creation call. public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op variable creation call. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); /// No-op NodeAdded model-change announcement. @@ -2363,7 +2363,7 @@ public sealed class AddressSpaceApplierTests /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } /// No-op folder creation call. /// The folder node ID. /// The parent folder node ID, if any. @@ -2376,7 +2376,7 @@ public sealed class AddressSpaceApplierTests /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } /// No-op rebuild address space call. public void RebuildAddressSpace() { } /// No-op NodeAdded model-change announcement. 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 d73a0b28..6a4c66c3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs @@ -14,8 +14,8 @@ public sealed class DeferredAddressSpaceSinkTests var deferred = new DeferredAddressSpaceSink(); // No throw, no observable side effect. - deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow); - deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow); + deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns); deferred.RebuildAddressSpace(); } @@ -27,10 +27,10 @@ public sealed class DeferredAddressSpaceSinkTests var inner = new RecordingSink(); deferred.SetSink(inner); - deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow); - deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow); + deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns); deferred.RebuildAddressSpace(); - deferred.RaiseNodesAddedModelChange("eq-1"); + deferred.RaiseNodesAddedModelChange("eq-1", AddressSpaceRealm.Uns); inner.Calls.ShouldBe(new[] { "WV:x", "WA:a-1", "RB", "NA:eq-1" }); } @@ -42,11 +42,11 @@ public sealed class DeferredAddressSpaceSinkTests var deferred = new DeferredAddressSpaceSink(); var inner = new RecordingSink(); deferred.SetSink(inner); - deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow); + deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); inner.Calls.Count.ShouldBe(1); deferred.SetSink(null); - deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow); // dropped + deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); // dropped inner.Calls.Count.ShouldBe(1); } @@ -59,10 +59,10 @@ public sealed class DeferredAddressSpaceSinkTests var second = new RecordingSink(); deferred.SetSink(first); - deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow); + deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); deferred.SetSink(second); - deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow); + deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); first.Calls.Single().ShouldBe("WV:a"); second.Calls.Single().ShouldBe("WV:b"); @@ -77,7 +77,7 @@ public sealed class DeferredAddressSpaceSinkTests var inner = new RecordingSink(); deferred.SetSink(inner); - deferred.EnsureVariable("v-1", null, "MyVar", "Float", writable: false, historianTagname: "MyTag.PV"); + deferred.EnsureVariable("v-1", null, "MyVar", "Float", writable: false, historianTagname: "MyTag.PV", realm: AddressSpaceRealm.Uns); var call = inner.HistorianCalls.ShouldHaveSingleItem(); call.NodeId.ShouldBe("v-1"); @@ -96,7 +96,7 @@ public sealed class DeferredAddressSpaceSinkTests deferred.SetSink(inner); ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: "MyTag.PV", - dataType: "Int32", isArray: true, arrayLength: 8u) + dataType: "Int32", isArray: true, arrayLength: 8u, AddressSpaceRealm.Uns) .ShouldBeTrue(); var call = inner.SurgicalCalls.ShouldHaveSingleItem(); @@ -119,7 +119,7 @@ public sealed class DeferredAddressSpaceSinkTests deferred.SetSink(new SurgicalRecordingSink { Result = false }); ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: false, historianTagname: null, - dataType: "Float", isArray: false, arrayLength: null) + dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns) .ShouldBeFalse(); } @@ -131,12 +131,12 @@ public sealed class DeferredAddressSpaceSinkTests { var deferred = new DeferredAddressSpaceSink(); // default inner = null sink (not surgical) ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null, - dataType: "Float", isArray: false, arrayLength: null) + dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns) .ShouldBeFalse(); deferred.SetSink(new RecordingSink()); // a non-surgical inner ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null, - dataType: "Float", isArray: false, arrayLength: null) + dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns) .ShouldBeFalse(); } @@ -151,7 +151,7 @@ public sealed class DeferredAddressSpaceSinkTests var inner = new SurgicalRecordingSink { Result = true }; deferred.SetSink(inner); - ((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "Plant South") + ((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns) .ShouldBeTrue(); var call = inner.FolderRenameCalls.ShouldHaveSingleItem(); @@ -167,11 +167,11 @@ public sealed class DeferredAddressSpaceSinkTests var deferred = new DeferredAddressSpaceSink(); // Inner reports the folder missing ⇒ forward returns false. deferred.SetSink(new SurgicalRecordingSink { Result = false }); - ((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse(); + ((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X", AddressSpaceRealm.Uns).ShouldBeFalse(); // Non-surgical inner (the null sink before swap-in) ⇒ false. deferred.SetSink(null); - ((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse(); + ((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X", AddressSpaceRealm.Uns).ShouldBeFalse(); } /// R2-07 Phase 2 — the three surgical remove members forward to a surgical inner with @@ -184,9 +184,9 @@ public sealed class DeferredAddressSpaceSinkTests var inner = new SurgicalRecordingSink { Result = true }; deferred.SetSink(inner); - ((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("eq-1/A").ShouldBeTrue(); - ((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("alm-1").ShouldBeTrue(); - ((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeTrue(); + ((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("eq-1/A", AddressSpaceRealm.Uns).ShouldBeTrue(); + ((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("alm-1", AddressSpaceRealm.Uns).ShouldBeTrue(); + ((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue(); inner.RemoveCalls.ShouldBe(new[] { ("var", "eq-1/A"), ("alarm", "alm-1"), ("equipment", "eq-1") }); } @@ -198,14 +198,14 @@ public sealed class DeferredAddressSpaceSinkTests { var deferred = new DeferredAddressSpaceSink(); deferred.SetSink(new SurgicalRecordingSink { Result = false }); - ((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1").ShouldBeFalse(); - ((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1").ShouldBeFalse(); - ((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeFalse(); + ((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse(); + ((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse(); + ((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse(); deferred.SetSink(null); - ((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1").ShouldBeFalse(); - ((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1").ShouldBeFalse(); - ((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeFalse(); + ((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse(); + ((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse(); + ((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse(); } /// Builds a minimal for the forwarding tests (the @@ -234,13 +234,13 @@ public sealed class DeferredAddressSpaceSinkTests public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"WA:{alarmNodeId}"); /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) => CallQueue.Enqueue($"MA:{alarmNodeId}"); /// public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"EF:{folderNodeId}"); /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { CallQueue.Enqueue($"EV:{variableNodeId}"); HistorianQueue.Enqueue((variableNodeId, historianTagname)); @@ -290,11 +290,11 @@ public sealed class DeferredAddressSpaceSinkTests /// public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } /// public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + 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() { } /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs index 88b11f2c..4dcf68b2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs @@ -27,9 +27,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var (host, server) = await BootAsync(); var sink = new SdkAddressSpaceSink(server.NodeManager!); - sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow); - sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow); - sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow); + sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns); server.NodeManager!.VariableCount.ShouldBe(2); @@ -44,8 +44,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var (host, server) = await BootAsync(); var sink = new SdkAddressSpaceSink(server.NodeManager!); - sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow); - sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow); + sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); server.NodeManager!.VariableCount.ShouldBe(2); @@ -120,16 +120,16 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var (host, server) = await BootAsync(); var sink = new SdkAddressSpaceSink(server.NodeManager!); - sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow); - sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow); - sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow); + sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns); server.NodeManager!.VariableCount.ShouldBe(3); sink.RebuildAddressSpace(); server.NodeManager.VariableCount.ShouldBe(0); // After rebuild, subsequent writes start fresh. - sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow); + sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); server.NodeManager.VariableCount.ShouldBe(1); await host.DisposeAsync(); @@ -142,8 +142,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable // Sanity check that the F10 fallback still works — production callers default to // NullOpcUaAddressSpaceSink when no SDK NodeManager is wired. var sink = NullOpcUaAddressSpaceSink.Instance; - sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow); - sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow); + sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns); sink.RebuildAddressSpace(); await Task.CompletedTask; } @@ -161,10 +161,10 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var sink = new SdkAddressSpaceSink(nm); // Equipment folder must exist first (MaterialiseHierarchy owns this in production). - sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); + sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", AddressSpaceRealm.Uns); // Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it. - sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700); + sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); nm.AlarmConditionCount.ShouldBe(1); @@ -201,7 +201,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable // WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra // BaseDataVariable is minted for the alarm id. - sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow); + sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns); nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken condition.ActiveState.Id.Value.ShouldBeTrue(); @@ -209,7 +209,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain // Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id. - sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700); + sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); nm.AlarmConditionCount.ShouldBe(1); // RebuildAddressSpace clears the alarm dict too. @@ -228,8 +228,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); - sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9"); - sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500); + sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", AddressSpaceRealm.Uns); + sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-x"); condition.ShouldNotBeNull(); @@ -250,8 +250,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); - sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2"); - sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300); + sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", AddressSpaceRealm.Uns); + sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-rich"); condition.ShouldNotBeNull(); @@ -267,7 +267,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable Shelving: AlarmShelvingKind.Timed, Severity: 850, Message: "Pressure above limit"), - DateTime.UtcNow); + DateTime.UtcNow, AddressSpaceRealm.Uns); condition.ActiveState.Id.Value.ShouldBeTrue(); condition.AckedState.Id.Value.ShouldBeFalse(); @@ -296,8 +296,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); - sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3"); - sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200); + sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", AddressSpaceRealm.Uns); + sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-base"); condition.ShouldNotBeNull(); @@ -318,7 +318,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable Shelving: AlarmShelvingKind.OneShot, Severity: 500, Message: "still works"), - DateTime.UtcNow)); + DateTime.UtcNow, AddressSpaceRealm.Uns)); // Mandatory state still projected despite the missing optional child. condition.ActiveState.Id.Value.ShouldBeTrue(); @@ -346,8 +346,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); - sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt"); - sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700); + sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt", AddressSpaceRealm.Uns); + sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-evt"); condition.ShouldNotBeNull(); @@ -357,7 +357,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone(); // First engine-driven transition → fires an event with a fresh EventId. - sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow); + sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns); var firstEventId = (byte[]?)condition.EventId.Value?.Clone(); firstEventId.ShouldNotBeNull(); @@ -369,7 +369,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable } // Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous). - sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow); + sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow, AddressSpaceRealm.Uns); var secondEventId = (byte[]?)condition.EventId.Value?.Clone(); secondEventId.ShouldNotBeNull(); @@ -395,8 +395,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); - sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2"); - sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700); + sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2", AddressSpaceRealm.Uns); + sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-evt2"); condition.ShouldNotBeNull(); @@ -404,9 +404,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable // Drive several transitions; each fires an event AND projects state. State must survive firing. Should.NotThrow(() => { - sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow); - sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow); - sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow); + sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns); }); // Final projected state is intact after the last firing. @@ -434,14 +434,14 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); - sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack"); - sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700); + sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack", AddressSpaceRealm.Uns); + sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-ack"); condition.ShouldNotBeNull(); // Drive the alarm active+unacked through the engine path (a genuine transition → fires). - sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow); + sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns); condition!.ActiveState.Id.Value.ShouldBeTrue(); condition.AckedState.Id.Value.ShouldBeFalse(); @@ -462,7 +462,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable // === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) === // Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees // no change ⇒ NO event fires. - sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow); + sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns); var afterReProject = (byte[]?)condition.EventId.Value?.Clone(); // EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed. @@ -486,8 +486,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); - sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta"); - sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700); + sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta", AddressSpaceRealm.Uns); + sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-delta"); condition.ShouldNotBeNull(); @@ -496,19 +496,19 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable // Genuine transition: snapshot (active, unacked) differs from the materialise state // (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes). - sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow); + sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns); var afterFirst = (byte[]?)condition.EventId.Value?.Clone(); afterFirst.ShouldNotBeNull(); afterFirst!.ShouldNotBe(beforeFirst); // fired // Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more // events (EventId unchanged from the first fire). - sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow); + sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns); var afterSecond = (byte[]?)condition.EventId.Value?.Clone(); afterSecond.ShouldBe(afterFirst); // suppressed // A FURTHER genuine transition (clear) differs again ⇒ fires once more. - sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow); + sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns); var afterThird = (byte[]?)condition.EventId.Value?.Clone(); afterThird.ShouldNotBe(afterSecond); // fired 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 3a80d6ec..33509853 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 @@ -331,14 +331,14 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// No-op: alarm materialise is not exercised by this suite. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } /// Records an EnsureFolder call. public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _folders.Enqueue((folderNodeId, parentNodeId, displayName)); /// Records an EnsureVariable call. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) => _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); /// Records a raw rebuild (the apply-time fallback when no dbFactory is wired). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs index 64bcb14a..085fee91 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs @@ -238,10 +238,10 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase // collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) — // fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY // (a lowercase-only "h1" check would miss a leaked raw "H1"). - eqANodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-A", "FOCAS/Identity", "Model")); + eqANodeId.ShouldBe(V3NodeIds.Uns("EQ-A", "FOCAS", "Identity", "Model")); eqANodeId.ShouldNotContain("H1"); eqANodeId.ShouldNotContain("h1"); - eqBNodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-B", "FOCAS/Status", "Run")); + eqBNodeId.ShouldBe(V3NodeIds.Uns("EQ-B", "FOCAS", "Status", "Run")); eqBNodeId.ShouldNotContain("h2"); // (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs index 75000f7f..d18f88a6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs @@ -18,20 +18,20 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// -/// Verifies the equipment-tag live-value routing wired into : -/// a driver publishes a value keyed by its wire-ref FullName, but the equipment variable was -/// materialised under a FOLDER-SCOPED NodeId ({equipmentId}/{folderPath}/{name}). After an -/// apply, the host's _nodeIdByDriverRef map resolves (DriverInstanceId, FullName) to -/// that folder-scoped NodeId, so ForwardToMux lands the value on the right node (and still -/// forwards the raw value to the dependency mux for VirtualTag inputs). -/// +/// v3 Batch 4 (B4-WP3) — the driver single-source fan-out wired into +/// . The driver publishes a value keyed by its wire-ref (== the tag's +/// RawPath); the host's _nodeIdByDriverRef map — rebuilt each apply from the composition's +/// RawTagsUnsReferenceVariables — resolves (DriverInstanceId, RawPath) to the RAW +/// NodeId AND every referencing UNS NodeId, so ForwardToMux lands ONE publish on the raw node +/// () plus every UNS node () with +/// IDENTICAL value/quality/timestamp (no independent buffer ⇒ no drift). The raw value is still forwarded +/// to the dependency mux (VirtualTag inputs), keyed by the RawPath. /// -/// Drives a real apply through the existing harness: the seeded artifact carries the -/// Namespaces / DriverInstances / Tags arrays that -/// DeploymentArtifact.BuildEquipmentTagPlans needs to project equipment tags, and a -/// DispatchDeployment triggers the ApplyAndAck → PushDesiredSubscriptions pass -/// that builds the map. The OPC UA sink and the dependency mux are injected as -/// s. +/// Drives a real apply through the existing harness: the seeded artifact carries the v3 raw-tag chain +/// (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional UnsTagReference projecting +/// each raw tag into an equipment. DispatchDeployment triggers the +/// ApplyAndAck → PushDesiredSubscriptions pass that builds the map; the sink and mux are +/// injected as s. /// /// public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase @@ -40,55 +40,95 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly DateTime Ts = new(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc); - /// A driver value published by FullName lands on the equipment variable's folder-scoped - /// NodeId (here eq-1/speed, no sub-folder), carrying the value/quality/timestamp. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] - public void Driver_value_routes_to_folder_scoped_equipment_NodeId() + /// A driver value published by RawPath fans to the RAW node AND the referencing UNS node, each + /// carrying identical value/quality/timestamp — the fan-out drift guard. + [Fact] + public void Driver_value_fans_out_to_raw_and_uns_NodeIds_with_identical_value() { var db = NewInMemoryDbFactory(); - // One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed". - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed")); + var deploymentId = SeedV3Deployment(db, RevA, + rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) }, + unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) }); var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId); + // The driver publishes by its wire-ref RawPath. actor.Tell(new DriverInstanceActor.AttributeValuePublished( - "drv-1", "40001", 42.0, OpcUaQuality.Good, Ts)); + "drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaQuality.Good, Ts)); - var update = publish.ExpectMsg(TimeSpan.FromSeconds(5)); - update.NodeId.ShouldBe("eq-1/speed"); - update.Value.ShouldBe(42.0); - update.Quality.ShouldBe(OpcUaQuality.Good); - update.TimestampUtc.ShouldBe(Ts); + var a = publish.ExpectMsg(TimeSpan.FromSeconds(5)); + var b = publish.ExpectMsg(TimeSpan.FromSeconds(5)); + var byId = new[] { a, b }.ToDictionary(u => u.NodeId); - // The raw value is still forwarded to the dependency mux (VirtualTag inputs, keyed by FullName). + byId.Keys.ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" }, ignoreOrder: true); + // Realm travels with each update so the sink resolves the right namespace. + byId["Plant/Modbus/dev1/speed"].Realm.ShouldBe(AddressSpaceRealm.Raw); + byId["filling/line1/station1/speed"].Realm.ShouldBe(AddressSpaceRealm.Uns); + // Fan-out drift guard: identical value + quality + timestamp on BOTH node states. + foreach (var u in byId.Values) + { + u.Value.ShouldBe(42.0); + u.Quality.ShouldBe(OpcUaQuality.Good); + u.TimestampUtc.ShouldBe(Ts); + } + + // The raw publish still reaches the dependency mux (VirtualTag inputs), keyed by RawPath. mux.ExpectMsg(TimeSpan.FromSeconds(5)) - .FullReference.ShouldBe("40001"); + .FullReference.ShouldBe("Plant/Modbus/dev1/speed"); } - /// The same driver ref backing two equipments fans out: a single publish produces one - /// per equipment variable NodeId. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] - public void Same_ref_on_two_equipments_fans_out_to_both_NodeIds() + /// A raw tag with NO UNS reference fans to ONLY its raw NodeId (Raw realm). + [Fact] + public void Unreferenced_raw_tag_fans_to_raw_node_only() { var db = NewInMemoryDbFactory(); - // Same (drv-1, "40001") wire-ref backs eq-1/speed AND eq-2/speed (identical machines). - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"), - (Equip: "eq-2", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed")); + var deploymentId = SeedV3Deployment(db, RevA, + rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) }, + unsRefs: Array.Empty<(string, string, string, string, string?)>()); var (actor, publish, _) = SpawnHostAndApply(db, deploymentId); actor.Tell(new DriverInstanceActor.AttributeValuePublished( - "drv-1", "40001", 7.0, OpcUaQuality.Good, Ts)); + "drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaQuality.Good, Ts)); - // One publish → two updates. Assert the SET of NodeIds (order is not contractual). - var first = publish.ExpectMsg(TimeSpan.FromSeconds(5)); - var second = publish.ExpectMsg(TimeSpan.FromSeconds(5)); - new[] { first.NodeId, second.NodeId }.ShouldBe( - new[] { "eq-1/speed", "eq-2/speed" }, ignoreOrder: true); - first.Value.ShouldBe(7.0); - second.Value.ShouldBe(7.0); + var only = publish.ExpectMsg(TimeSpan.FromSeconds(5)); + only.NodeId.ShouldBe("Plant/Modbus/dev1/speed"); + only.Realm.ShouldBe(AddressSpaceRealm.Raw); + publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } + + /// A raw tag referenced by TWO equipments fans to the raw node PLUS both UNS nodes (3 updates, + /// identical value) — the 1:N fan-out. + [Fact] + public void Raw_tag_referenced_by_two_equipments_fans_to_raw_plus_both_uns() + { + var db = NewInMemoryDbFactory(); + var deploymentId = SeedV3Deployment(db, RevA, + rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) }, + unsRefs: new[] + { + (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null), + (Area: "filling", Line: "line1", Equip: "station2", BackingTag: "speed", Effective: (string?)null), + }); + + var (actor, publish, _) = SpawnHostAndApply(db, deploymentId); + + actor.Tell(new DriverInstanceActor.AttributeValuePublished( + "drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaQuality.Good, Ts)); + + var updates = new[] + { + publish.ExpectMsg(TimeSpan.FromSeconds(5)), + publish.ExpectMsg(TimeSpan.FromSeconds(5)), + publish.ExpectMsg(TimeSpan.FromSeconds(5)), + }; + updates.Select(u => u.NodeId).ShouldBe(new[] + { + "Plant/Modbus/dev1/speed", + "filling/line1/station1/speed", + "filling/line1/station2/speed", + }, ignoreOrder: true); + updates.ShouldAllBe(u => (double)u.Value! == 7.0); } /// A value for a ref not in the composition is NOT pushed to the OPC UA sink (no matching @@ -97,25 +137,23 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase public void Unmatched_ref_is_dropped_from_sink_but_still_forwarded_to_mux() { var db = NewInMemoryDbFactory(); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed")); + var deploymentId = SeedV3Deployment(db, RevA, + rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) }, + unsRefs: Array.Empty<(string, string, string, string, string?)>()); var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId); actor.Tell(new DriverInstanceActor.AttributeValuePublished( - "drv-1", "59999", 99.0, OpcUaQuality.Good, Ts)); + "drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaQuality.Good, Ts)); - // No equipment-tag NodeId for ("drv-1","59999") → nothing reaches the sink. publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - // The raw publish still reaches the dependency mux. mux.ExpectMsg(TimeSpan.FromSeconds(5)) - .FullReference.ShouldBe("59999"); + .FullReference.ShouldBe("Plant/Modbus/dev1/nope"); } /// Spawns the host with publish + mux probes, dispatches the deployment, and waits for the - /// Applied ACK so the apply (and thus the map build in PushDesiredSubscriptions) has completed - /// before the test publishes a value. A VirtualTag-host probe is injected so the real host isn't - /// spawned (it would consume the mux-forwarded publishes otherwise — see SpawnVirtualTagHost). + /// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a + /// value. A VirtualTag-host probe is injected so the real host isn't spawned. private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Mux) SpawnHostAndApply( IDbContextFactory db, DeploymentId deploymentId) { @@ -134,47 +172,68 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // RebuildAddressSpace also lands on the publish probe during apply; drain it so the test's - // ExpectMsg assertions see only value updates. publish.ExpectMsg(TimeSpan.FromSeconds(5)); return (actor, publish, mux); } - /// - /// Seeds a Sealed deployment whose artifact carries the minimal arrays - /// DeploymentArtifact.BuildEquipmentTagPlans needs to project equipment tags: - /// Namespaces (one Equipment-kind ns), DriverInstances (each driver bound to that - /// ns), and Tags (each with a non-null EquipmentId + a TagConfig blob carrying FullName). - /// - private static DeploymentId SeedDeploymentWithEquipmentTags( + /// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" → + /// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an + /// Area/Line/Equipment. Each raw tag's RawPath is Plant/{DriverName}/{Device}/{Tag}; each UNS + /// reference's NodeId is {Area}/{Line}/{Equip}/{Effective ?? backing tag Name}. Enums serialize + /// numerically (AccessLevel: ReadWrite = 1). + private static DeploymentId SeedV3Deployment( IDbContextFactory db, RevisionHash rev, - params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags) + (string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags, + (string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs) { - var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray(); + var drivers = rawTags + .Select(t => (t.Driver, t.DriverName)) + .Distinct() + .Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false }) + .ToArray(); + var devices = rawTags + .Select(t => (t.Driver, t.Device)) + .Distinct() + .Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" }) + .ToArray(); + var tags = rawTags.Select(t => new + { + TagId = t.Tag, // tag id == name for test simplicity (unique per driver here) + DeviceId = $"{t.Driver}:{t.Device}", + TagGroupId = (string?)null, + Name = t.Tag, + DataType = t.DataType, + AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1 + TagConfig = "{}", + }).ToArray(); + + // UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip). + var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal) + .Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray(); + var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct() + .Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray(); + var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct() + .Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray(); + var unsTagReferences = unsRefs.Select((r, i) => new + { + UnsTagReferenceId = $"ref-{i}", + EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}", + TagId = r.BackingTag, + DisplayNameOverride = r.Effective, + }).ToArray(); var artifact = JsonSerializer.SerializeToUtf8Bytes(new { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = driverIds.Select(d => new - { - DriverInstanceId = d, - NamespaceId = "ns-eq", - }).ToArray(), - Tags = tags.Select((t, i) => new - { - TagId = $"tag-{i}", - EquipmentId = t.Equip, - DriverInstanceId = t.Driver, - Name = t.Name, - FolderPath = t.Folder, - DataType = "Double", - TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }), - }).ToArray(), + RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = drivers, + Devices = devices, + TagGroups = Array.Empty(), + Tags = tags, + UnsAreas = areas, + UnsLines = lines, + Equipment = equipment, + UnsTagReferences = unsTagReferences, }); var id = DeploymentId.NewId(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs index af733d2a..3f9c1527 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs @@ -20,22 +20,17 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// -/// Verifies the inbound operator-write routing wired into : a -/// for a materialised equipment-variable NodeId resolves -/// the NodeId → (DriverInstanceId, FullName) reverse map (built alongside the forward map in -/// PushDesiredSubscriptions), gates on this node being the driver PRIMARY (reusing the same -/// RedundancyStateChanged signal the alarm-emit gate uses), forwards a -/// carrying the driver-side FullName to the -/// right driver child, and replies a to the asker. -/// +/// v3 Batch 4 (B4-WP3) — inbound operator-write routing through EITHER NodeId. A +/// carries the FULL ns-qualified NodeId string (the node +/// manager's write hook passes node.NodeId.ToString()); the host normalises it to the bare id and +/// resolves the NodeId → (DriverInstanceId, RawPath) reverse map — populated in +/// PushDesiredSubscriptions for BOTH the raw NodeId AND every referencing UNS NodeId — gates on the +/// driver PRIMARY, and forwards a carrying the tag's +/// RawPath to the driver child. So a write to the raw node and a write to the referencing UNS node +/// both reach the SAME driver point (they share the driver ref). /// -/// Drives a real apply through the existing harness (same artifact shape as -/// DriverHostActorLiveValueTests) so the reverse map is populated authentically and a real -/// (non-stubbed) child is spawned. The child is backed by a -/// recording driver so the test can observe the forwarded write and assert -/// the no-write case on the secondary. There is no test seam to inject a TestProbe as a -/// driver child, so this end-to-end approach (recording driver) is the closest faithful test the -/// harness allows. +/// Drives a real apply (Enabled Modbus driver ⇒ a real child backed +/// by a recording driver), then routes writes and asserts the forwarded wire-ref + the primary gate. /// /// public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase @@ -44,48 +39,68 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); - /// On the PRIMARY, a RouteNodeWrite for a mapped NodeId forwards the driver-side FullName to - /// the right driver child (observed via the recording driver) and replies NodeWriteResult(true). - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] - public void Primary_routes_write_to_driver_by_full_name_and_replies_success() + // The single seeded raw tag + its UNS reference. + private const string RawPath = "Plant/Modbus/dev1/speed"; + private const string UnsNodeId = "filling/line1/station1/speed"; + + /// On the PRIMARY, a RouteNodeWrite for the ns-qualified UNS NodeId resolves to the raw tag's + /// driver ref and forwards the write keyed by the tag's RawPath (dual-NodeId write routing). + [Fact] + public void Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath() { var db = NewInMemoryDbFactory(); var recorder = new RecordingDriverFactory("Modbus"); - // One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed" → NodeId "eq-1/speed". - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed")); + var deploymentId = SeedV3Deployment(db, RevA); var actor = SpawnHostAndApply(db, deploymentId, recorder); - // Local role unknown ⇒ treated as Primary ⇒ write allowed (default-emit semantics). var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); + // The node manager passes the FULL ns-qualified id; the host must normalise + resolve it. + actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref); - var result = asker.ExpectMsg(Timeout); - result.Success.ShouldBeTrue(); - - // The driver received exactly the write, keyed by its wire-ref FullName (not the NodeId). + asker.ExpectMsg(Timeout).Success.ShouldBeTrue(); AwaitAssert(() => { recorder.Writes.Count.ShouldBe(1); - recorder.Writes[0].FullReference.ShouldBe("40001"); + recorder.Writes[0].FullReference.ShouldBe(RawPath); // the driver writes by RawPath, not the UNS id recorder.Writes[0].Value.ShouldBe(123.0); }, duration: Timeout); } - /// On a SECONDARY, RouteNodeWrite replies NodeWriteResult(false, "not primary") and the - /// driver receives NO write — the primary gate fires before the reverse-map lookup. + /// On the PRIMARY, a RouteNodeWrite for the ns-qualified RAW NodeId resolves to the same driver + /// ref and forwards the write keyed by the RawPath. + [Fact] + public void Primary_routes_write_via_raw_nodeid_to_driver_by_rawpath() + { + var db = NewInMemoryDbFactory(); + var recorder = new RecordingDriverFactory("Modbus"); + var deploymentId = SeedV3Deployment(db, RevA); + + var actor = SpawnHostAndApply(db, deploymentId, recorder); + + var asker = CreateTestProbe(); + actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0), asker.Ref); + + asker.ExpectMsg(Timeout).Success.ShouldBeTrue(); + AwaitAssert(() => + { + recorder.Writes.Count.ShouldBe(1); + recorder.Writes[0].FullReference.ShouldBe(RawPath); + recorder.Writes[0].Value.ShouldBe(456.0); + }, duration: Timeout); + } + + /// On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver + /// receives NO write — the primary gate fires before the reverse-map lookup. [Fact] public void Secondary_rejects_write_and_does_not_forward_to_driver() { var db = NewInMemoryDbFactory(); var recorder = new RecordingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed")); + var deploymentId = SeedV3Deployment(db, RevA); var actor = SpawnHostAndApply(db, deploymentId, recorder); - // Force this node Secondary so the primary gate rejects. actor.Tell(new RedundancyStateChanged( new[] { @@ -95,29 +110,26 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase CorrelationId.NewId())); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref); var result = asker.ExpectMsg(Timeout); result.Success.ShouldBeFalse(); result.Reason.ShouldBe("not primary"); - - // No write reached the driver — the gate short-circuited before the reverse-map lookup. recorder.Writes.ShouldBeEmpty(); } - /// An unknown NodeId (no reverse-map entry) replies NodeWriteResult(false) and writes nothing. + /// An unknown NodeId (no reverse-map entry) replies failure and writes nothing. [Fact] public void Unknown_node_id_replies_failure() { var db = NewInMemoryDbFactory(); var recorder = new RecordingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed")); + var deploymentId = SeedV3Deployment(db, RevA); var actor = SpawnHostAndApply(db, deploymentId, recorder); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/does-not-exist", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0), asker.Ref); var result = asker.ExpectMsg(Timeout); result.Success.ShouldBeFalse(); @@ -125,49 +137,11 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase recorder.Writes.ShouldBeEmpty(); } - /// A protocol TagConfig blob with no FullName key routes by the equipment NodeId, and - /// the forwarded wire-ref is the raw blob verbatim. ExtractTagFullName falls back to the raw - /// blob string when no top-level FullName property is present, so the reverse map keys on - /// (DriverInstanceId, <raw-blob>) and the driver receives that exact string as its - /// WriteRequest.FullReference — not a FullName value extracted from the blob. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] - public void Primary_routes_write_for_raw_protocol_blob_tag() - { - var db = NewInMemoryDbFactory(); - var recorder = new RecordingDriverFactory("Modbus"); - // Seed the tag with a genuine protocol blob that has NO FullName key (pure Modbus wire config). - // ExtractTagFullName falls back to returning the raw blob string verbatim, so that string IS the - // wire-ref the reverse map keys on and the driver receives as WriteRequest.FullReference. - var (deploymentId, rawBlobString) = SeedDeploymentWithRawBlobTag(db, RevA, - equip: "eq-2", driver: "drv-2", name: "torque"); - - var actor = SpawnHostAndApply(db, deploymentId, recorder); - - // Local role unknown ⇒ treated as Primary ⇒ write allowed. - var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-2/torque", 456.0), asker.Ref); - - var result = asker.ExpectMsg(Timeout); - result.Success.ShouldBeTrue(); - - // The forwarded wire-ref is the raw blob string verbatim — ExtractTagFullName fell back because - // there is no top-level "FullName" key in the blob. - AwaitAssert(() => - { - recorder.Writes.Count.ShouldBe(1); - recorder.Writes[0].FullReference.ShouldBe(rawBlobString); - recorder.Writes[0].Value.ShouldBe(456.0); - }, duration: Timeout); - } - - /// A RouteNodeWrite arriving while the host is Stale (config DB unreachable) must fast-fail - /// with an immediate negative NodeWriteResult (reason mentions "stale") instead of dead-lettering into - /// the node-manager's bounded-Ask timeout. Drives the host into Stale via a DB factory whose - /// CreateDbContext throws on bootstrap (the same fall-through to Become(Stale) production uses). + /// A RouteNodeWrite arriving while the host is Stale (config DB unreachable) fast-fails with an + /// immediate negative reply mentioning "stale". [Fact] public void Stale_host_fast_fails_route_node_write() { - // A factory that always throws on CreateDbContext ⇒ Bootstrap's try fails ⇒ Become(Stale). var db = new ThrowingDbFactory(); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( @@ -175,7 +149,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase localRoles: new HashSet { "driver" })); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref); var result = asker.ExpectMsg(TimeSpan.FromSeconds(2)); result.Success.ShouldBeFalse(); @@ -183,10 +157,9 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase result.Reason!.ShouldContain("stale"); } - /// Spawns the host with the recording driver factory, dispatches the deployment, and waits - /// for the Applied ACK so the apply (and thus the reverse-map build in PushDesiredSubscriptions) has - /// completed before the test routes a write. No OPC UA / mux probes are wired — this test exercises - /// only the write path, which doesn't depend on the publish actor. + /// Spawns the host with the recording driver factory, dispatches the deployment, and waits for + /// the Applied ACK so the reverse-map build in PushDesiredSubscriptions has completed before routing a + /// write. No OPC UA / mux probes are wired — the write path doesn't depend on the publish actor. private IActorRef SpawnHostAndApply( IDbContextFactory db, DeploymentId deploymentId, IDriverFactory factory) { @@ -198,49 +171,32 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - return actor; } - /// - /// Seeds a Sealed deployment whose artifact carries the minimal arrays - /// DeploymentArtifact.BuildEquipmentTagPlans needs to project equipment tags. Mirrors - /// DriverHostActorLiveValueTests.SeedDeploymentWithEquipmentTags but also carries a - /// DriverInstances row with a non-Windows-only DriverType ("Modbus") + Enabled flag - /// so a REAL (non-stubbed) child is spawned for the write path. - /// - private static DeploymentId SeedDeploymentWithEquipmentTags( - IDbContextFactory db, RevisionHash rev, - params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags) + /// Seeds a Sealed v3 deployment: RawFolder "Plant" → DriverInstance "drv-1" (Modbus, ENABLED so a + /// real child spawns) → Device "dev1" → Tag "speed" (RawPath Plant/Modbus/dev1/speed, ReadWrite), + /// projected into equipment filling/line1/station1 by a UnsTagReference (UNS NodeId + /// filling/line1/station1/speed). + private static DeploymentId SeedV3Deployment(IDbContextFactory db, RevisionHash rev) { - var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray(); - var artifact = JsonSerializer.SerializeToUtf8Bytes(new { - Namespaces = new[] + RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = new[] { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 + new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true }, }, - DriverInstances = driverIds.Select(d => new + Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, + TagGroups = Array.Empty(), + Tags = new[] { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = d, - Name = d, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }).ToArray(), - Tags = tags.Select((t, i) => new - { - TagId = $"tag-{i}", - EquipmentId = t.Equip, - DriverInstanceId = t.Driver, - Name = t.Name, - FolderPath = t.Folder, - DataType = "Double", - TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }), - }).ToArray(), + new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" }, + }, + UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } }, + UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } }, + Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } }, + UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-speed", DisplayNameOverride = (string?)null } }, }); var id = DeploymentId.NewId(); @@ -258,77 +214,8 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase return id; } - /// - /// Seeds a single-tag Sealed deployment whose tag's TagConfig is a genuine protocol-driver - /// blob with no FullName key (pure Modbus wire config: - /// {"region":"HoldingRegister","address":200,"dataType":"UInt16"}). Because - /// ExtractTagFullName finds no top-level FullName property, it falls back to - /// returning the raw blob string verbatim — that raw string becomes the - /// (DriverInstanceId, <raw-blob>) reverse-map key, and the driver receives it as - /// WriteRequest.FullReference. Returns both the and the exact - /// raw blob string so the caller can assert the forwarded wire-ref precisely. - /// - private static (DeploymentId DeploymentId, string RawBlobString) SeedDeploymentWithRawBlobTag( - IDbContextFactory db, RevisionHash rev, - string equip, string driver, string name) - { - // Serialize the blob with NO FullName field — ExtractTagFullName will fall back to this verbatim. - var rawBlobString = JsonSerializer.Serialize( - new { region = "HoldingRegister", address = 200, dataType = "UInt16" }); - - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = new[] - { - new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = driver, - Name = driver, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }, - }, - Tags = new[] - { - new - { - TagId = "tag-raw", - EquipmentId = equip, - DriverInstanceId = driver, - Name = name, - FolderPath = (string?)null, - DataType = "Double", - // Pure protocol blob: no FullName key. ExtractTagFullName falls back to raw blob. - TagConfig = rawBlobString, - }, - }, - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return (id, rawBlobString); - } - - /// An whose CreateDbContext always throws, - /// driving 's bootstrap into the catchBecome(Stale) path - /// so a write can be routed at a Stale host. + /// An whose CreateDbContext always throws — drives the host + /// into the Stale path. private sealed class ThrowingDbFactory : IDbContextFactory { /// @@ -336,15 +223,14 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase throw new InvalidOperationException("config DB unreachable (test stub)"); } - /// Factory producing a single for the supported type, whose - /// recorded write list is exposed for assertions. + /// Factory producing a single for the supported type. private sealed class RecordingDriverFactory : IDriverFactory { private readonly string _supportedType; private readonly RecordingDriver _driver = new(); public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; } - /// The writes the spawned driver received (thread-safe — WriteAsync runs off the actor thread). + /// The writes the spawned driver received. public IReadOnlyList Writes => _driver.Writes; /// @@ -389,7 +275,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase { foreach (var w in writes) _writes.Enqueue(w); return Task.FromResult>( - writes.Select(_ => new WriteResult(0u)).ToArray()); // 0x0 = Good + writes.Select(_ => new WriteResult(0u)).ToArray()); } } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs index 32220a41..6a11e797 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs @@ -175,15 +175,19 @@ public sealed class AddHistorianProvisioningTests prov.Seen[0].DataType.ShouldBe(DriverDataType.Float32); } - private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref") - => new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName, - Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName); + // v3 Batch 4: historized value tags are RAW tags (the applier provisions from AddedRawTags). NodeId plays + // the RawPath role (== mux ref + historian default). + private static RawTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref") + => new("tag-" + displayName, NodeId: fullName, ParentNodeId: null, DriverInstanceId: "drv", Name: displayName, + DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty(), + IsHistorized: true, HistorianTagname: historianName); - private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType) - => new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref", - Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null); + private static RawTagPlan NonHistorizedTag(string displayName, string dataType) + => new("tag-" + displayName, NodeId: "ref", ParentNodeId: null, DriverInstanceId: "drv", Name: displayName, + DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty(), + IsHistorized: false, HistorianTagname: null); - private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new( + private static AddressSpacePlan PlanWithAddedTags(params RawTagPlan[] tags) => new( AddedEquipment: Array.Empty(), RemovedEquipment: Array.Empty(), ChangedEquipment: Array.Empty(), @@ -194,6 +198,6 @@ public sealed class AddHistorianProvisioningTests RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()) { - AddedEquipmentTags = tags, + AddedRawTags = tags, }; } 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 6759ee96..bf3fe3a4 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 @@ -206,7 +206,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } /// Ensures folder exists (stub implementation). /// The folder node identifier. /// The parent folder node identifier. @@ -219,7 +219,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } /// Rebuilds address space (recorded via span). public void RebuildAddressSpace() { /* recorded via span */ } /// Announces a NodeAdded model-change (stub implementation). 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 a97da8f0..5883c093 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 @@ -120,9 +120,9 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase { public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + 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 AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } @@ -133,9 +133,9 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase { public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + 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 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 1d2731db..6b8f6a87 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 @@ -451,7 +451,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) => Calls.Enqueue($"MA:{alarmNodeId}"); /// Records a folder ensure call. /// The folder node ID. @@ -466,7 +466,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) => Calls.Enqueue($"EV:{variableNodeId}"); /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); 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 c9c25bc3..1f9e0ba6 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 @@ -622,7 +622,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase /// The condition display name. /// The domain alarm type. /// The domain severity. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } /// Records a folder ensure call. /// The OPC UA folder node identifier. @@ -638,7 +638,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase /// The OPC UA built-in type name. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) => VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); /// Records a rebuild call. From 2e0743ad2529a004016e0276337afe835205332d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 11:30:13 -0400 Subject: [PATCH 06/14] fix(v3-batch4-wp3): realm-qualified write routing + dormant discovery guard + self-correction/byte-parity tests (Wave B review H1/M1/M2/L1/L3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 (HIGH): write-routing key now (AddressSpaceRealm, bareId), not bare-only. A raw s= and a UNS s= can collide as bare strings; the bare-only key let a colliding raw+UNS pair route to the WRONG driver ref (last-writer-wins). The realm the node manager resolves (RealmOf) is now threaded through IOpcUaNodeWriteGateway.WriteAsync -> RouteNodeWrite -> _driverRefByNodeId keyed by (realm, bareId). New regression test: Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm. M1 (MEDIUM): discovered-node injection made coherently DORMANT. HandleDiscoveredNodes hard-short-circuits (single enforcement point; _discoveredByDriver never populates so the re-inject tail is inert too), with a clear log pointing at the /raw browse-commit flow. New pin: Discovered_nodes_are_ignored_dormant_in_v3; the 16+2 v2 injection scenarios re-pointed to an accurate skip reason (DiscoveryInjectionDormantV3). M2 (MEDIUM): realm-qualified dual-node self-correction tests — Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched + Raw_realm_revert_reverts_raw_node_only (the second fails if the realm is dropped). L1: removed the = AddressSpaceRealm.Uns defaults from the consequential node-manager mutation methods (WriteValue/WriteAlarmCondition/MaterialiseAlarmCondition/ EnsureFolder/EnsureVariable/UpdateFolderDisplayName/UpdateTagAttributes/ RaiseNodesAddedModelChange/Remove*/RevertOptimisticWriteIfNeeded) + the AttributeValueUpdate/AlarmStateUpdate records, so the compiler forces explicit realm; read-only accessors + internal builders retain their defaults. L3: fixed the stale VirtualTagHostActor class comment (V3NodeIds.Uns, not the retired EquipmentNodeIds.Variable). Also: DeploymentArtifactRawUnsParityTests — Raw/UNS node-set byte-parity round-trip between AddressSpaceComposer.Compose and DeploymentArtifact.ParseComposition. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../OpcUa/IOpcUaNodeWriteGateway.cs | 12 +- .../OtOpcUaNodeManager.cs | 26 ++-- .../SdkAddressSpaceSink.cs | 4 +- .../Drivers/ActorNodeWriteGateway.cs | 4 +- .../Drivers/DriverHostActor.cs | 67 ++++++--- .../OpcUa/OpcUaPublishActor.cs | 4 +- .../ScriptedAlarms/ScriptedAlarmHostActor.cs | 4 +- .../VirtualTags/VirtualTagHostActor.cs | 10 +- .../OpcUa/NullOpcUaNodeWriteGatewayTests.cs | 2 +- .../PrimaryGateFailoverTests.cs | 3 +- .../AlarmCommandRouterTests.cs | 100 ++++++------- ...eManagerAlarmIdempotentMaterialiseTests.cs | 21 +-- .../NodeManagerArrayTests.cs | 10 +- .../NodeManagerHistorizeTests.cs | 11 +- .../NodeManagerHistoryReadConcurrencyTests.cs | 3 +- .../NodeManagerHistoryReadEventsTests.cs | 27 ++-- .../NodeManagerHistoryReadPagingTests.cs | 17 +-- .../NodeManagerHistoryReadTests.cs | 23 +-- .../NodeManagerModelChangeOnAddTests.cs | 12 +- .../NodeManagerPreStartGuardTests.cs | 8 +- .../NodeManagerSurgicalRemoveTests.cs | 49 +++---- .../NodeManagerSurgicalShapeUpdateTests.cs | 38 ++--- .../NodeManagerWriteRevertTests.cs | 92 ++++++++++-- .../Drivers/ActorNodeWriteGatewayTests.cs | 9 +- .../Drivers/DarkAddressSpaceReasons.cs | 12 ++ .../DeploymentArtifactRawUnsParityTests.cs | 84 +++++++++++ .../DiscoveryInjectionEndToEndTests.cs | 4 +- .../Drivers/DriverHostActorDiscoveryTests.cs | 59 +++++--- .../DriverHostActorPrimaryGateTests.cs | 11 +- .../DriverHostActorProbeResultDropTests.cs | 3 +- .../DriverHostActorWriteRoutingTests.cs | 132 +++++++++++++++++- .../OtOpcUaTelemetryHookTests.cs | 2 +- .../OpcUa/OpcUaPublishActorTests.cs | 10 +- .../ScriptedAlarmHostActorTests.cs | 8 +- 34 files changed, 616 insertions(+), 265 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaNodeWriteGateway.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaNodeWriteGateway.cs index 4469005f..17a10b80 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaNodeWriteGateway.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaNodeWriteGateway.cs @@ -11,11 +11,17 @@ public interface IOpcUaNodeWriteGateway { /// Route a write of to the driver backing node /// ; the returned task resolves with the device-write outcome. - /// The folder-scoped equipment-variable node id being written. + /// The full ns-qualified node id being written (node.NodeId.ToString()). /// The value the client wrote. + /// Which of the two v3 namespaces ( device tree or + /// equipment tree) lives in — the node + /// manager resolves it from the node's namespace index (RealmOf). It is REQUIRED for correct + /// routing: a raw s=<RawPath> and a UNS s=<Area/Line/Equip/Eff> can collide as bare + /// strings, so the routing map is keyed by (realm, bareId) — dropping the realm would let an + /// operator write route to the WRONG driver ref. /// Cancellation token. /// A task resolving to the device-write outcome. - Task WriteAsync(string nodeId, object? value, CancellationToken ct); + Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct); } /// Outcome of routing an inbound node write to the backing driver. @@ -31,6 +37,6 @@ public sealed class NullOpcUaNodeWriteGateway : IOpcUaNodeWriteGateway public static readonly NullOpcUaNodeWriteGateway Instance = new(); private NullOpcUaNodeWriteGateway() { } /// - public Task WriteAsync(string nodeId, object? value, CancellationToken ct) => + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => Task.FromResult(new NodeWriteOutcome(false, "writes unavailable")); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 5f8b6169..e0f3d357 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -349,7 +349,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The new value to write. /// The OPC UA quality status code. /// The timestamp of the value in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(nodeId); EnsureAddressSpaceCreated(); @@ -386,7 +386,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node identifier of the alarm (== ScriptedAlarmId for materialised conditions). /// The full condition state to project onto the node. /// The timestamp of the alarm state change in UTC. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentNullException.ThrowIfNull(state); @@ -678,7 +678,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// . LimitAlarm deliberately falls back to base per the T13 /// notes — a script alarm carries no High/Low limits to populate. /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); @@ -1000,7 +1000,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed // task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never // re-enters lock (Lock) while CustomNodeManager2.Write still holds it. - _ = gateway.WriteAsync(nodeKey, optimisticValue, CancellationToken.None) + _ = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None) .ContinueWith( t => { @@ -1206,7 +1206,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// from to populate the audit event's ClientUserId; null when unknown. internal void RevertOptimisticWriteIfNeeded( string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus, - string? clientUserId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + string? clientUserId, AddressSpaceRealm realm) { // Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released. AuditWriteUpdateEventState? auditEvent = null; @@ -1398,7 +1398,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node identifier of the folder. /// The node identifier of the parent folder; null to use the namespace root. /// The display name of the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(folderNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); @@ -1443,7 +1443,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node identifier of the folder to update in place. /// The new display name to apply. /// True when the in-place update was applied; false when the folder id is unknown. - public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(folderNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); @@ -1485,7 +1485,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// — rank + dimensions carry the array-ness. /// Phase 4c: the declared length of the 1-D array when /// is true; ignored for scalars. Null ⇒ length 0. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); @@ -1583,7 +1583,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar. /// The declared length of the 1-D array when is true; ignored for scalars. /// True when the in-place update was applied; false when the node id is unknown. - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(dataType); // widened surface ⇒ explicit contract (unknown names still map to BaseDataType) @@ -1712,7 +1712,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// /// /// The folder-scoped node id of the parent under which nodes were added. - public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(affectedNodeId); GeneralModelChangeEventState e; @@ -2004,7 +2004,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// - public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); EnsureAddressSpaceCreated(); @@ -2028,7 +2028,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// - public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); EnsureAddressSpaceCreated(); @@ -2053,7 +2053,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// - public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId); EnsureAddressSpaceCreated(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index 87927ae7..084ba026 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -30,7 +30,7 @@ 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, isNative, realm); + => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative); /// public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) @@ -38,7 +38,7 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre /// public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) - => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm); + => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); /// public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ActorNodeWriteGateway.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ActorNodeWriteGateway.cs index ccb91771..7c6cb37e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ActorNodeWriteGateway.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ActorNodeWriteGateway.cs @@ -43,7 +43,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway } /// - public async Task WriteAsync(string nodeId, object? value, CancellationToken ct) + public async Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) { var driverHost = _resolveDriverHost(); if (driverHost is null) @@ -55,7 +55,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway try { var result = await driverHost.Ask( - new DriverHostActor.RouteNodeWrite(nodeId, value), _askTimeout, ct).ConfigureAwait(false); + new DriverHostActor.RouteNodeWrite(nodeId, value, realm), _askTimeout, ct).ConfigureAwait(false); if (!result.Success) _logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, result.Reason); return new NodeWriteOutcome(result.Success, result.Reason); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 46e99fbf..7723c662 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -128,19 +128,22 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet> _nodeIdByDriverRef = new(); /// - /// Inverse of : materialised value NodeId (BARE s= id) → - /// (DriverInstanceId, RawPath). v3 Batch 4: keyed by BOTH the raw NodeId AND every referencing UNS - /// NodeId (all mapping to the same driver ref — the single value source). Rebuilt every apply by - /// from the composition's RawTags ∪ - /// UnsReferenceVariables, and resolved by so an inbound - /// operator write to EITHER NodeId is forwarded to the owning driver child as a write of its wire-ref - /// RawPath. Keyed by the BARE id: the node manager's write hook passes the full ns-qualified id - /// (node.NodeId.ToString()); normalises it to the bare id - /// before lookup, so a raw write and a UNS write both resolve to the same driver ref (they share it — - /// a UNS reference's backing RawPath IS the raw node's, so no cross-realm ambiguity is possible). + /// Inverse of : (Realm, BARE s= id) → (DriverInstanceId, + /// RawPath). v3 Batch 4: keyed by BOTH the raw NodeId () AND every + /// referencing UNS NodeId () — all mapping to the same driver ref + /// (the single value source). Rebuilt every apply by from the + /// composition's RawTagsUnsReferenceVariables, and resolved by + /// so an inbound operator write to EITHER NodeId is forwarded to the + /// owning driver child as a write of its wire-ref RawPath. + /// The realm is part of the key (Wave B review H1): a raw s=<RawPath> and a UNS + /// s=<Area/Line/Equip/Eff> can collide as bare strings (folder/driver/device names and + /// Area/Line/Equip names are independent), so a bare-only key would let a colliding raw + UNS pair route + /// to the WRONG driver ref (last-writer-wins). The node manager's write hook passes the full + /// ns-qualified id PLUS the realm it resolved (RealmOf); + /// normalises the id to the bare form and looks up (realm, bareId), preserving exactly the + /// namespace disambiguation the ns-qualified id carries. /// - private readonly Dictionary _driverRefByNodeId = - new(StringComparer.Ordinal); + private readonly Dictionary<(AddressSpaceRealm Realm, string NodeId), (string DriverInstanceId, string RawPath)> _driverRefByNodeId = new(); /// (DriverInstanceId, RawPath = alarm ConditionId) → materialised condition NodeId(s) (realm-tagged). /// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); WP4 adds the referencing @@ -241,7 +244,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// /// The folder-scoped equipment-variable NodeId the operator wrote to. /// The value to write (the driver coerces it to the attribute's data type). - public sealed record RouteNodeWrite(string NodeId, object? Value); + public sealed record RouteNodeWrite(string NodeId, object? Value, AddressSpaceRealm Realm); /// Reply to : the outcome of forwarding the write to the driver /// (or a gate/lookup failure that never reached the driver). @@ -654,6 +657,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg) { + // v3 Batch 4 (review M1): discovered-node INJECTION is DORMANT in v3 and hard-guarded here. In v2 a + // driver-connected FixedTree was grafted under an equipment folder (equipment bound a driver); v3 + // retired that binding — equipment references raw tags via UnsTagReference, and discovered raw tags are + // authored explicitly through the Batch-2 /raw browse-commit flow, NOT injected at runtime. The + // downstream mapper/materialiser were half-migrated to the Raw tree but are still rooted at an + // equipment id, so letting this fire would materialise incoherent nodes. Short-circuit BEFORE any + // caching/routing so _discoveredByDriver stays empty (the redeploy re-inject tail is therefore inert + // too) and there is exactly one enforcement point. Re-migrating injection onto the raw device subtree + // is a separate follow-up. + _log.Debug( + "DriverHost {Node}: discovered-node injection is dormant in v3 (DiscoveredNodesReady from {Driver} ignored; discovered raw tags are authored via /raw browse-commit)", + _localNode, msg.DriverInstanceId); + return; + +#pragma warning disable CS0162 // Unreachable code — retained for the raw-subtree re-migration follow-up. if (_lastComposition is null) { _log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored", @@ -728,6 +746,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers _discoveredByDriver[msg.DriverInstanceId] = newPlans; ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans); +#pragma warning restore CS0162 } /// @@ -933,7 +952,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route // through the Raw realm. set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw)); - _driverRefByNodeId[nodeId] = key; + _driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key; } _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes( equipmentId, plan.Folders, plan.Variables)); @@ -1101,15 +1120,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers return; } - // v3 Batch 4: the node manager's write hook passes the FULL ns-qualified NodeId string - // (node.NodeId.ToString(), e.g. "ns=3;s="). The routing map is keyed by the BARE s= identifier - // (the raw RawPath or the UNS path). Normalise before lookup so a write to EITHER a raw NodeId or a - // referencing UNS NodeId resolves to the SAME driver ref (they share it — a UNS reference's backing - // RawPath IS the raw node's, so there is no cross-realm ambiguity). + // v3 Batch 4 (review H1): the node manager's write hook passes the FULL ns-qualified NodeId string + // (node.NodeId.ToString(), e.g. "ns=3;s=") PLUS the realm it resolved from the namespace index. + // The routing map is keyed by (realm, BARE s= identifier). Normalise the id to its bare form and look + // up (realm, bareId) — the realm disambiguates a raw RawPath from a UNS path that happen to share a + // bare string, so a write always routes to its OWN driver ref (never a colliding sibling's). var bareNodeId = BareNodeId(msg.NodeId); - if (!_driverRefByNodeId.TryGetValue(bareNodeId, out var target)) + if (!_driverRefByNodeId.TryGetValue((msg.Realm, bareNodeId), out var target)) { - Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId}")); + Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId} ({msg.Realm})")); return; } @@ -1564,14 +1583,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) _nodeIdByDriverRef[key] = set = new HashSet(); set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw)); - _driverRefByNodeId[t.NodeId] = key; + _driverRefByNodeId[(AddressSpaceRealm.Raw, t.NodeId)] = key; if (unsRefsByRawPath.TryGetValue(t.NodeId, out var refs)) { foreach (var v in refs) { set.Add(new NodeRealmRef(v.NodeId, AddressSpaceRealm.Uns)); - _driverRefByNodeId[v.NodeId] = key; // a UNS write resolves to the same driver ref + // A UNS write resolves to the same driver ref — keyed under the Uns realm so it can never + // collide with a raw NodeId that shares its bare string. + _driverRefByNodeId[(AddressSpaceRealm.Uns, v.NodeId)] = key; } } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index b49b623e..15e0fe51 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -45,7 +45,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// referencing UNS node in ) with identical value/quality/timestamp. /// defaults to so pre-v3 callers (VirtualTag /// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node. - public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns); + public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm); /// Carries the full Part 9 condition state for a scripted alarm to the sink. The /// snapshot is the Commons projection the Runtime host maps from the engine's @@ -57,7 +57,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// The source timestamp of the transition in UTC. /// The namespace realm the condition lives in — for /// scripted alarms (default), for v3 native raw conditions. - public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns); + public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm); /// /// Triggers an address-space rebuild. is the deployment /// just applied by the host; the rebuild loads THAT artifact so materialisation matches the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs index a8d790b8..0edea434 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs @@ -297,10 +297,12 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor // Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/ // shelving/severity/message) onto the materialised condition node via the Commons snapshot. // e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId). + // Scripted alarms are per-equipment condition nodes in the UNS realm. _publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate( AlarmNodeId: e.AlarmId, State: ToSnapshot(e), - TimestampUtc: e.TimestampUtc)); + TimestampUtc: e.TimestampUtc, + Realm: AddressSpaceRealm.Uns)); // Publish the transition to the cluster `alerts` topic — the single historization + live // fan-out path. The mediator was cached on the ACTOR thread in PreStart; we only Tell it here. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs index 961f2fad..4229d68d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs @@ -19,9 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; /// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value. /// /// -/// The published NodeId is computed by the shared — -/// the single source of truth AddressSpaceApplier.MaterialiseEquipmentVirtualTags also -/// materialises against — so the value always lands on a NodeId that exists. +/// The published NodeId is computed via (the equipment-anchored +/// slash path {EquipmentId}[/{FolderPath}]/{Name}) — byte-identical to the NodeId +/// AddressSpaceApplier.MaterialiseEquipmentVirtualTags materialises against — so the value +/// always lands on a NodeId that exists. /// /// public sealed class VirtualTagHostActor : ReceiveActor @@ -192,8 +193,9 @@ public sealed class VirtualTagHostActor : ReceiveActor // 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script // failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the // sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good. + // VirtualTags materialise as equipment nodes in the UNS realm — publish there. _publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate( - nodeId, result.Value, result.Quality, result.TimestampUtc)); + nodeId, result.Value, result.Quality, result.TimestampUtc, AddressSpaceRealm.Uns)); // Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so // no parallel map. The historian path key is the SAME folder-scoped NodeId we just published diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/NullOpcUaNodeWriteGatewayTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/NullOpcUaNodeWriteGatewayTests.cs index dc7a2899..60cc02d7 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/NullOpcUaNodeWriteGatewayTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/NullOpcUaNodeWriteGatewayTests.cs @@ -9,7 +9,7 @@ public class NullOpcUaNodeWriteGatewayTests [Fact] public async Task NullGateway_returns_writes_unavailable() { - var outcome = await NullOpcUaNodeWriteGateway.Instance.WriteAsync("ns=2;s=x", 1, TestContext.Current.CancellationToken); + var outcome = await NullOpcUaNodeWriteGateway.Instance.WriteAsync("ns=2;s=x", 1, AddressSpaceRealm.Uns, TestContext.Current.CancellationToken); outcome.Success.ShouldBeFalse(); outcome.Reason.ShouldBe("writes unavailable"); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/PrimaryGateFailoverTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/PrimaryGateFailoverTests.cs index c9ca2b92..d32e42b9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/PrimaryGateFailoverTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/PrimaryGateFailoverTests.cs @@ -2,6 +2,7 @@ using Akka.Actor; using Akka.Hosting; using Microsoft.Extensions.DependencyInjection; using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Xunit; using ZB.MOM.WW.OtOpcUa.Runtime; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; @@ -68,7 +69,7 @@ public sealed class PrimaryGateFailoverTests private static async Task RouteProbeWriteAsync(IActorRef driverHost) => await driverHost.Ask( - new DriverHostActor.RouteNodeWrite(UnmappedProbeNode, 0.0), TimeSpan.FromSeconds(10)); + new DriverHostActor.RouteNodeWrite(UnmappedProbeNode, 0.0, AddressSpaceRealm.Uns), TimeSpan.FromSeconds(10)); private static bool IsGateReject(DriverHostActor.NodeWriteResult r) => !r.Success && r.Reason is not null && r.Reason.StartsWith("not primary", StringComparison.Ordinal); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs index e5f9f928..210c15c2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs @@ -34,8 +34,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-1"); condition.ShouldNotBeNull(); condition!.OnAcknowledge.ShouldNotBeNull(); @@ -66,8 +66,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2"); - nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-2"); condition.ShouldNotBeNull(); @@ -91,8 +91,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3"); - nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-3"); condition.ShouldNotBeNull(); @@ -115,8 +115,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C"); - nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-c"); condition.ShouldNotBeNull(); condition!.OnConfirm.ShouldNotBeNull(); @@ -142,8 +142,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC"); - nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-ac"); condition.ShouldNotBeNull(); condition!.OnAddComment.ShouldNotBeNull(); @@ -168,8 +168,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1"); - nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-s1"); condition.ShouldNotBeNull(); condition!.OnShelve.ShouldNotBeNull(); @@ -196,8 +196,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2"); - nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-s2"); condition.ShouldNotBeNull(); @@ -228,8 +228,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3"); - nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-s3"); condition.ShouldNotBeNull(); @@ -254,8 +254,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4"); - nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-s4"); condition.ShouldNotBeNull(); @@ -287,8 +287,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU"); - nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-tu"); condition.ShouldNotBeNull(); condition!.OnTimedUnshelve.ShouldNotBeNull(); @@ -322,8 +322,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1"); - nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); + nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-ed1"); condition.ShouldNotBeNull(); condition!.OnEnableDisable.ShouldNotBeNull(); @@ -351,8 +351,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2"); - nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); + nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-ed2"); condition.ShouldNotBeNull(); condition!.OnEnableDisable.ShouldNotBeNull(); @@ -379,8 +379,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3"); - nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); + nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-ed3"); condition.ShouldNotBeNull(); @@ -405,8 +405,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4"); - nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); + nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-ed4"); condition.ShouldNotBeNull(); condition!.OnEnableDisable.ShouldNotBeNull(); @@ -436,8 +436,8 @@ public sealed class AlarmCommandRouterTests : IDisposable nm.AlarmCommandRouter = scripted.Add; nm.NativeAlarmAckRouter = native.Add; - nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1"); - nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); + nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-nak1"); condition.ShouldNotBeNull(); condition!.OnAcknowledge.ShouldNotBeNull(); @@ -469,8 +469,8 @@ public sealed class AlarmCommandRouterTests : IDisposable nm.AlarmCommandRouter = scripted.Add; nm.NativeAlarmAckRouter = native.Add; - nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2"); - nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); + nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-nak2"); condition.ShouldNotBeNull(); condition!.OnAcknowledge.ShouldNotBeNull(); @@ -503,8 +503,8 @@ public sealed class AlarmCommandRouterTests : IDisposable nm.AlarmCommandRouter = scripted.Add; nm.NativeAlarmAckRouter = native.Add; - nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4"); - nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); + nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-nak4"); condition.ShouldNotBeNull(); @@ -531,8 +531,8 @@ public sealed class AlarmCommandRouterTests : IDisposable nm.AlarmCommandRouter = scripted.Add; nm.NativeAlarmAckRouter = native.Add; - nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3"); - nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); + nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-nak3"); condition.ShouldNotBeNull(); @@ -555,8 +555,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var nm = server.NodeManager!; // No router set (default null). - nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR"); - nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-nr"); condition.ShouldNotBeNull(); @@ -576,8 +576,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true); + nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns); nm.IsNativeAlarmNode("a1").ShouldBeTrue(); @@ -591,8 +591,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false); + nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); nm.IsNativeAlarmNode("a2").ShouldBeFalse(); @@ -611,15 +611,15 @@ public sealed class AlarmCommandRouterTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true); + nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns); nm.IsNativeAlarmNode("a1").ShouldBeTrue(); // RebuildAddressSpace clears the folder set too, so the equipment folder must be re-ensured // before the same id can be re-materialised (ResolveParentFolder needs the parent back). nm.RebuildAddressSpace(); - nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false); + nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); nm.IsNativeAlarmNode("a1").ShouldBeFalse(); @@ -636,13 +636,13 @@ public sealed class AlarmCommandRouterTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false); + nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); nm.IsNativeAlarmNode("a1").ShouldBeFalse(); nm.RebuildAddressSpace(); - nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true); + nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns); nm.IsNativeAlarmNode("a1").ShouldBeTrue(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmIdempotentMaterialiseTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmIdempotentMaterialiseTests.cs index c34a3151..6b62c032 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmIdempotentMaterialiseTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmIdempotentMaterialiseTests.cs @@ -1,4 +1,5 @@ using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Xunit; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; @@ -27,14 +28,14 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); var first = nm.TryGetAlarmCondition("alm-1"); first.ShouldNotBeNull(); // Same id + same kind ⇒ skip-if-present: the existing instance is kept. - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); var second = nm.TryGetAlarmCondition("alm-1"); second.ShouldBeSameAs(first); @@ -50,14 +51,14 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); var scripted = nm.TryGetAlarmCondition("alm-1"); nm.IsNativeAlarmNode("alm-1").ShouldBeFalse(); // Same id but the OTHER kind ⇒ recreate (a different instance) and the native flag is now set. - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: true); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns); var native = nm.TryGetAlarmCondition("alm-1"); native.ShouldNotBeSameAs(scripted); @@ -75,16 +76,16 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); var before = nm.TryGetAlarmCondition("alm-1"); nm.RebuildAddressSpace(); nm.TryGetAlarmCondition("alm-1").ShouldBeNull(); - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); var after = nm.TryGetAlarmCondition("alm-1"); after.ShouldNotBeNull(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs index 2dfce4d1..642fa81f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs @@ -32,7 +32,7 @@ public sealed class NodeManagerArrayTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/arr", parentFolderNodeId: null, displayName: "arr", dataType: "Int32", - writable: false, historianTagname: null, isArray: true, arrayLength: 8); + writable: false, historianTagname: null, isArray: true, arrayLength: 8, realm: AddressSpaceRealm.Uns); var variable = nm.TryGetVariable("eq-1/arr"); variable.ShouldNotBeNull(); @@ -52,7 +52,7 @@ public sealed class NodeManagerArrayTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/arr-unfixed", parentFolderNodeId: null, displayName: "arr-unfixed", dataType: "Int32", - writable: false, historianTagname: null, isArray: true, arrayLength: null); + writable: false, historianTagname: null, isArray: true, arrayLength: null, realm: AddressSpaceRealm.Uns); var variable = nm.TryGetVariable("eq-1/arr-unfixed"); variable.ShouldNotBeNull(); @@ -72,7 +72,7 @@ public sealed class NodeManagerArrayTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/scalar", parentFolderNodeId: null, displayName: "scalar", dataType: "Int32", - writable: false); + writable: false, realm: AddressSpaceRealm.Uns); var variable = nm.TryGetVariable("eq-1/scalar"); variable.ShouldNotBeNull(); @@ -91,10 +91,10 @@ public sealed class NodeManagerArrayTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/arrwrite", parentFolderNodeId: null, displayName: "arrwrite", dataType: "Int32", - writable: false, historianTagname: null, isArray: true, arrayLength: 3); + writable: false, historianTagname: null, isArray: true, arrayLength: 3, realm: AddressSpaceRealm.Uns); var payload = new[] { 1, 2, 3 }; - nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow); + nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var variable = nm.TryGetVariable("eq-1/arrwrite"); variable.ShouldNotBeNull(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistorizeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistorizeTests.cs index d4e4f8a1..8e236777 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistorizeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistorizeTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Opc.Ua; using Shouldly; using Xunit; @@ -29,7 +30,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", - writable: false, historianTagname: "WW.Tag"); + writable: false, historianTagname: "WW.Tag", realm: AddressSpaceRealm.Uns); var variable = nm.TryGetVariable("eq-1/temp"); variable.ShouldNotBeNull(); @@ -56,9 +57,9 @@ public sealed class NodeManagerHistorizeTests : IDisposable // Explicit null and the defaulted-param form both mean "not historized". nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32", - writable: false, historianTagname: null); + writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns); nm.EnsureVariable("eq-1/plain2", parentFolderNodeId: null, displayName: "Plain2", dataType: "Int32", - writable: false); + writable: false, realm: AddressSpaceRealm.Uns); foreach (var nodeId in new[] { "eq-1/plain", "eq-1/plain2" }) { @@ -83,7 +84,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/setpoint", parentFolderNodeId: null, displayName: "Setpoint", dataType: "Float", - writable: true, historianTagname: "WW.Setpoint"); + writable: true, historianTagname: "WW.Setpoint", realm: AddressSpaceRealm.Uns); var variable = nm.TryGetVariable("eq-1/setpoint"); variable.ShouldNotBeNull(); @@ -109,7 +110,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", - writable: false, historianTagname: "WW.Tag"); + writable: false, historianTagname: "WW.Tag", realm: AddressSpaceRealm.Uns); nm.TryGetHistorizedTagname("eq-1/temp", out _).ShouldBeTrue(); nm.RebuildAddressSpace(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs index f8e97331..a8658918 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; @@ -228,7 +229,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable { var key = $"eq/tag{i}"; nm.EnsureVariable(key, parentFolderNodeId: null, displayName: $"Tag{i}", dataType: "Float", - writable: false, historianTagname: $"WW.Tag{i}"); + writable: false, historianTagname: $"WW.Tag{i}", realm: AddressSpaceRealm.Uns); ids[i] = nm.TryGetVariable(key)!.NodeId; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs index 20a14ccf..28bba442 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Opc.Ua; using Opc.Ua.Server; using Shouldly; @@ -42,8 +43,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable nm.HistorianDataSource = fake; const string equipmentId = "eq-evt"; - nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var evtTime = new DateTime(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc); @@ -107,8 +108,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable nm.HistorianDataSource = fake; const string equipmentId = "eq-unbounded"; - nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("alarm-0", equipmentId, "Cond", "OffNormalAlarm", severity: 600); + nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-0", equipmentId, "Cond", "OffNormalAlarm", severity: 600, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; fake.EventsResult = new HistoricalEventsResult( @@ -143,8 +144,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable nm.HistorianDataSource = fake; const string equipmentId = "eq-unsupported"; - nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500); + nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; fake.EventsResult = new HistoricalEventsResult( @@ -187,8 +188,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable nm.HistorianDataSource = fake; const string equipmentId = "eq-empty"; - nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300); + nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var details = new ReadEventDetails @@ -221,8 +222,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable // Materialise the alarm while the source is still the Null default — the folder is promoted to // SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration. const string equipmentId = "eq-nosrc"; - nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200); + nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; // Wire a real source AFTER promotion — it must NOT retroactively make the folder a source. @@ -271,7 +272,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable // A historized variable node — has AccessLevel.HistoryRead (variable-history reads) but // EventNotifier=None (no event-notifier bit). The SDK base rejects it before our override runs. nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", - writable: false, historianTagname: "WW.Temp"); + writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId; var details = new ReadEventDetails @@ -303,8 +304,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable nm.HistorianDataSource = fake; const string equipmentId = "eq-boom"; - nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900); + nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var details = new ReadEventDetails diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs index 16ddb42e..16d8bf29 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Opc.Ua; using Opc.Ua.Server; using Shouldly; @@ -42,7 +43,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/pv", parentFolderNodeId: null, displayName: "PV", dataType: "Double", - writable: false, historianTagname: "WW.PV"); + writable: false, historianTagname: "WW.PV", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/pv")!.NodeId; var collected = new List(); @@ -84,7 +85,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 100, stepSeconds: 1)); nm.EnsureVariable("eq-1/exact", parentFolderNodeId: null, displayName: "Exact", dataType: "Double", - writable: false, historianTagname: "WW.Exact"); + writable: false, historianTagname: "WW.Exact", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/exact")!.NodeId; var (r1, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null); @@ -123,7 +124,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/tie", parentFolderNodeId: null, displayName: "Tie", dataType: "Double", - writable: false, historianTagname: "WW.Tie"); + writable: false, historianTagname: "WW.Tie", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/tie")!.NodeId; var collected = new List(); @@ -167,7 +168,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/burst", parentFolderNodeId: null, displayName: "Burst", dataType: "Double", - writable: false, historianTagname: "WW.Burst"); + writable: false, historianTagname: "WW.Burst", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/burst")!.NodeId; var collected = new List(); @@ -211,7 +212,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/absurd", parentFolderNodeId: null, displayName: "Absurd", dataType: "Double", - writable: false, historianTagname: "WW.Absurd"); + writable: false, historianTagname: "WW.Absurd", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/absurd")!.NodeId; // Page 1: a full page of the first 2 ties, with a continuation point. @@ -241,7 +242,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); nm.EnsureVariable("eq-1/all", parentFolderNodeId: null, displayName: "All", dataType: "Double", - writable: false, historianTagname: "WW.All"); + writable: false, historianTagname: "WW.All", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/all")!.NodeId; var (r, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 0, inboundCp: null); @@ -265,7 +266,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/bad-cp", parentFolderNodeId: null, displayName: "BadCp", dataType: "Double", - writable: false, historianTagname: "WW.BadCp"); + writable: false, historianTagname: "WW.BadCp", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/bad-cp")!.NodeId; fake.ResetReadCount(); @@ -291,7 +292,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/rel", parentFolderNodeId: null, displayName: "Rel", dataType: "Double", - writable: false, historianTagname: "WW.Rel"); + writable: false, historianTagname: "WW.Rel", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/rel")!.NodeId; // Page 1 — get a CP. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs index 48ee37f5..12b4b9d8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Opc.Ua; using Opc.Ua.Server; using Shouldly; @@ -39,7 +40,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", - writable: false, historianTagname: "WW.Temp"); + writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId; var src = DateTime.UtcNow.AddSeconds(-5); @@ -91,7 +92,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable // Node id "eq-9/flow" but a DISTINCT historian tagname "Plant.Flow.PV". nm.EnsureVariable("eq-9/flow", parentFolderNodeId: null, displayName: "Flow", dataType: "Double", - writable: false, historianTagname: "Plant.Flow.PV"); + writable: false, historianTagname: "Plant.Flow.PV", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-9/flow")!.NodeId; var details = new ReadRawModifiedDetails @@ -123,7 +124,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/empty", parentFolderNodeId: null, displayName: "Empty", dataType: "Float", - writable: false, historianTagname: "WW.Empty"); + writable: false, historianTagname: "WW.Empty", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/empty")!.NodeId; var details = new ReadRawModifiedDetails @@ -156,7 +157,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable // Plain (non-historized) variable — no HistoryRead access bit. nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32", - writable: false, historianTagname: null); + writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/plain")!.NodeId; var details = new ReadRawModifiedDetails @@ -185,7 +186,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/mod", parentFolderNodeId: null, displayName: "Mod", dataType: "Float", - writable: false, historianTagname: "WW.Mod"); + writable: false, historianTagname: "WW.Mod", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/mod")!.NodeId; var details = new ReadRawModifiedDetails @@ -215,7 +216,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/avg", parentFolderNodeId: null, displayName: "Avg", dataType: "Float", - writable: false, historianTagname: "WW.Avg"); + writable: false, historianTagname: "WW.Avg", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/avg")!.NodeId; var details = new ReadProcessedDetails @@ -247,7 +248,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/sd", parentFolderNodeId: null, displayName: "Sd", dataType: "Float", - writable: false, historianTagname: "WW.Sd"); + writable: false, historianTagname: "WW.Sd", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/sd")!.NodeId; var details = new ReadProcessedDetails @@ -277,7 +278,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/at", parentFolderNodeId: null, displayName: "At", dataType: "Float", - writable: false, historianTagname: "WW.At"); + writable: false, historianTagname: "WW.At", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/at")!.NodeId; var t1 = DateTime.UtcNow.AddMinutes(-2); @@ -309,9 +310,9 @@ public sealed class NodeManagerHistoryReadTests : IDisposable // Materialise two distinct historized variables under separate equipment folders. nm.EnsureVariable("eqA/good", parentFolderNodeId: null, displayName: "Good", dataType: "Float", - writable: false, historianTagname: "A.PV"); + writable: false, historianTagname: "A.PV", realm: AddressSpaceRealm.Uns); nm.EnsureVariable("eqB/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Float", - writable: false, historianTagname: "B.PV"); + writable: false, historianTagname: "B.PV", realm: AddressSpaceRealm.Uns); var goodNodeId = nm.TryGetVariable("eqA/good")!.NodeId; var badNodeId = nm.TryGetVariable("eqB/bad")!.NodeId; @@ -368,7 +369,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/boom", parentFolderNodeId: null, displayName: "Boom", dataType: "Float", - writable: false, historianTagname: "WW.Boom"); + writable: false, historianTagname: "WW.Boom", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/boom")!.NodeId; var details = new ReadRawModifiedDetails diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs index 7b942b55..085112e4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs @@ -43,8 +43,8 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-7", parentNodeId: null, displayName: "Equipment 7"); - nm.EnsureVariable("eq-7/speed", parentFolderNodeId: "eq-7", displayName: "Speed", dataType: "Float", writable: false); + nm.EnsureFolder("eq-7", parentNodeId: null, displayName: "Equipment 7", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-7/speed", parentFolderNodeId: "eq-7", displayName: "Speed", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns); var parent = nm.TryGetFolder("eq-7")!; var e = nm.BuildNodesAddedModelChange("eq-7"); @@ -95,13 +95,13 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable var nm = server.NodeManager!; // Before any nodes exist under the parent — must not throw. - Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9")); + Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9", realm: AddressSpaceRealm.Uns)); - nm.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9"); - nm.EnsureVariable("eq-9/temp", parentFolderNodeId: "eq-9", displayName: "Temp", dataType: "Float", writable: false); + nm.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-9/temp", parentFolderNodeId: "eq-9", displayName: "Temp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns); // After the nodes are materialised — still must not throw. - Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9")); + Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9", realm: AddressSpaceRealm.Uns)); await host.DisposeAsync(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPreStartGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPreStartGuardTests.cs index 1f2490fd..2c8e5bcf 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPreStartGuardTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPreStartGuardTests.cs @@ -38,7 +38,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable try { var ex = Should.Throw(() => - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment")); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns)); ex.Message.ShouldContain("address space has not been created"); } finally @@ -55,7 +55,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable { Should.Throw(() => nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", - dataType: "Float", writable: false)); + dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns)); } finally { @@ -70,7 +70,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable try { Should.Throw(() => - nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow)); + nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns)); } finally { @@ -85,7 +85,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable try { Should.Throw(() => - nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500)); + nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns)); } finally { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs index fd225779..b064ddf0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs @@ -1,4 +1,5 @@ using Opc.Ua; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Shouldly; using Xunit; @@ -31,13 +32,13 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A"); - nm.EnsureVariable("eq-1/B", "eq-1", "B", "Float", writable: false); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-1/B", "eq-1", "B", "Float", writable: false, realm: AddressSpaceRealm.Uns); var countBefore = nm.VariableCount; nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeTrue(); - nm.RemoveVariableNode("eq-1/A").ShouldBeTrue(); + nm.RemoveVariableNode("eq-1/A", realm: AddressSpaceRealm.Uns).ShouldBeTrue(); nm.TryGetVariable("eq-1/A").ShouldBeNull(); nm.VariableCount.ShouldBe(countBefore - 1); @@ -56,7 +57,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.RemoveVariableNode("eq-1/nope").ShouldBeFalse(); + nm.RemoveVariableNode("eq-1/nope", realm: AddressSpaceRealm.Uns).ShouldBeFalse(); await host.DisposeAsync(); } @@ -68,8 +69,8 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, realm: AddressSpaceRealm.Uns); var e = nm.BuildNodesRemovedModelChange("eq-1/A"); @@ -92,17 +93,17 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns); nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldNotBeNull(); nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeTrue(); - nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeTrue(); + nm.RemoveAlarmConditionNode("eq-1/OverTemp", realm: AddressSpaceRealm.Uns).ShouldBeTrue(); nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldBeNull(); nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeFalse(); - nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeFalse(); // already gone ⇒ false + nm.RemoveAlarmConditionNode("eq-1/OverTemp", realm: AddressSpaceRealm.Uns).ShouldBeFalse(); // already gone ⇒ false await host.DisposeAsync(); } @@ -114,10 +115,10 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 500, isNative: false); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 500, isNative: false, realm: AddressSpaceRealm.Uns); - nm.RemoveAlarmConditionNode("alm-1").ShouldBeTrue(); + nm.RemoveAlarmConditionNode("alm-1", realm: AddressSpaceRealm.Uns).ShouldBeTrue(); nm.TryGetAlarmCondition("alm-1").ShouldBeNull(); await host.DisposeAsync(); @@ -136,17 +137,17 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable var nm = server.NodeManager!; // Target equipment eq-1 with a sub-folder, two variables (one historized), and a native condition. - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.EnsureFolder("eq-1/Diag", parentNodeId: "eq-1", displayName: "Diag"); - nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A"); - nm.EnsureVariable("eq-1/Diag/T", "eq-1/Diag", "T", "Float", writable: false); - nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.EnsureFolder("eq-1/Diag", parentNodeId: "eq-1", displayName: "Diag", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-1/Diag/T", "eq-1/Diag", "T", "Float", writable: false, realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns); // Sibling equipment eq-2 that must survive untouched. - nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2"); - nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false); + nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false, realm: AddressSpaceRealm.Uns); - nm.RemoveEquipmentSubtree("eq-1").ShouldBeTrue(); + nm.RemoveEquipmentSubtree("eq-1", realm: AddressSpaceRealm.Uns).ShouldBeTrue(); // Every eq-1 descendant is gone from every map. nm.TryGetFolder("eq-1").ShouldBeNull(); @@ -163,7 +164,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable // Re-materialising an alarm under eq-2 still works (the notifier machinery was not corrupted by the // eq-1 demotion) — proves no orphaned root-notifier ref broke the event path. - Should.NotThrow(() => nm.MaterialiseAlarmCondition("eq-2/Alm", "eq-2", "Alm", "OffNormalAlarm", 300, isNative: false)); + Should.NotThrow(() => nm.MaterialiseAlarmCondition("eq-2/Alm", "eq-2", "Alm", "OffNormalAlarm", 300, isNative: false, realm: AddressSpaceRealm.Uns)); await host.DisposeAsync(); } @@ -176,7 +177,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.RemoveEquipmentSubtree("eq-nope").ShouldBeFalse(); + nm.RemoveEquipmentSubtree("eq-nope", realm: AddressSpaceRealm.Uns).ShouldBeFalse(); await host.DisposeAsync(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs index d064246c..9205bf10 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs @@ -44,13 +44,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false); - nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; node.DataType.ShouldBe(DataTypeIds.Float); // arrange guard var applied = nm.UpdateTagAttributes("eq-1/sp", writable: false, historianTagname: null, - dataType: "Int32", isArray: false, arrayLength: null); + dataType: "Int32", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns); applied.ShouldBeTrue(); node.DataType.ShouldBe(DataTypeIds.Int32); // swapped in place @@ -69,13 +69,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false); - nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/buf")!; node.ValueRank.ShouldBe(ValueRanks.Scalar); // arrange guard var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, - dataType: "Int16", isArray: true, arrayLength: 8u); + dataType: "Int16", isArray: true, arrayLength: 8u, realm: AddressSpaceRealm.Uns); applied.ShouldBeTrue(); node.ValueRank.ShouldBe(ValueRanks.OneDimension); @@ -96,13 +96,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", - writable: false, historianTagname: null, isArray: true, arrayLength: 4u); - nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow); + writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/buf")!; node.ArrayDimensions![0].ShouldBe(4u); // arrange guard var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, - dataType: "Int16", isArray: true, arrayLength: 8u); + dataType: "Int16", isArray: true, arrayLength: 8u, realm: AddressSpaceRealm.Uns); applied.ShouldBeTrue(); node.ArrayDimensions[0].ShouldBe(8u); @@ -121,13 +121,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", - writable: false, historianTagname: null, isArray: true, arrayLength: 4u); - nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow); + writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/buf")!; node.ValueRank.ShouldBe(ValueRanks.OneDimension); // arrange guard var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, - dataType: "Int16", isArray: false, arrayLength: null); + dataType: "Int16", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns); applied.ShouldBeTrue(); node.ValueRank.ShouldBe(ValueRanks.Scalar); @@ -146,11 +146,11 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false); + nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/buf")!; var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, - dataType: "Int16", isArray: true, arrayLength: null); + dataType: "Int16", isArray: true, arrayLength: null, realm: AddressSpaceRealm.Uns); applied.ShouldBeTrue(); node.ValueRank.ShouldBe(ValueRanks.OneDimension); @@ -171,13 +171,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false); - nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; // Same DataType ("Float") + same scalar shape — only Writable flips false → true. var applied = nm.UpdateTagAttributes("eq-1/sp", writable: true, historianTagname: null, - dataType: "Float", isArray: false, arrayLength: null); + dataType: "Float", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns); applied.ShouldBeTrue(); node.Value.ShouldBe(7.0f); // value preserved (NOT reset) @@ -199,7 +199,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable bool result = true; Should.NotThrow(() => result = nm.UpdateTagAttributes("eq-1/gone", writable: false, historianTagname: null, - dataType: "Int32", isArray: false, arrayLength: null)); + dataType: "Int32", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns)); result.ShouldBeFalse(); await host.DisposeAsync(); @@ -215,7 +215,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; var e = nm.BuildNodeShapeChangedEvent(node); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs index 264a67dd..303b8827 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs @@ -50,9 +50,9 @@ public sealed class NodeManagerWriteRevertTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); // Prior state = Good 7; the SDK then optimistically applied 42 (the write the device will reject). - nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; node.Value = 42; // simulate the SDK's optimistic apply of the rejected write @@ -62,7 +62,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, - clientUserId: "op"); + clientUserId: "op", realm: AddressSpaceRealm.Uns); node.Value.ShouldBe(7); // settled back to prior value node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); // settled back to prior status (NOT stuck Bad) @@ -78,15 +78,15 @@ public sealed class NodeManagerWriteRevertTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); - nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; node.Value = 42; nm.RevertOptimisticWriteIfNeeded( "eq-1/sp", outcome: new NodeWriteOutcome(true, null), - optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns); node.Value.ShouldBe(42); // success ⇒ optimistic value stands @@ -101,15 +101,15 @@ public sealed class NodeManagerWriteRevertTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); - nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; node.Value = 99; // a fresh poll already republished the confirmed register value (NOT the optimistic 42) nm.RevertOptimisticWriteIfNeeded( "eq-1/sp", outcome: new NodeWriteOutcome(false, "device rejected"), - optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns); node.Value.ShouldBe(99); // poll value preserved — not reverted to 7 @@ -127,7 +127,71 @@ public sealed class NodeManagerWriteRevertTests : IDisposable Should.NotThrow(() => nm.RevertOptimisticWriteIfNeeded( "eq-1/gone", outcome: new NodeWriteOutcome(false, "device rejected"), - optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op")); + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns)); + + await host.DisposeAsync(); + } + + // ─────────────────── v3 Batch 4 (review M2) — realm-qualified dual-node self-correction ─────────────────── + + /// (M2a) A failed write driven through a UNS NodeId reverts ONLY the UNS node back to its prior + /// value; the raw node that shares the SAME bare id (but lives in the Raw realm) is NEVER touched and keeps + /// its driver value. The revert routes by (realm, bareId), so it can't clobber the sibling node. + [Fact] + public async Task Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + // Same bare id "A/B/C/D" in BOTH realms — the raw device node and the UNS reference node. + nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw); + nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); + var now = DateTime.UtcNow; + nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw); // raw driver value + nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns); // fanned UNS value + var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!; + var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!; + + uns.Value = 42; // the SDK optimistically applied the client's UNS write + + nm.RevertOptimisticWriteIfNeeded( + "A/B/C/D", outcome: new NodeWriteOutcome(false, "device rejected"), + optimisticValue: 42, priorValue: 10, priorStatus: StatusCodes.Good, clientUserId: "op", + realm: AddressSpaceRealm.Uns); + + uns.Value.ShouldBe(10); // UNS node reverted to prior + raw.Value.ShouldBe(10); // raw node NEVER changed (realm-qualified revert) + + await host.DisposeAsync(); + } + + /// (M2b) A raw-realm revert reverts the RAW node and leaves the same-bare-id UNS node untouched. + /// This locks the realm arg: were it omitted (defaulting to Uns) the revert would target the UNS node — whose + /// value is not the optimistic one, so the still-holds-optimistic guard skips it — leaving the raw node stuck + /// at the rejected value and FAILING the raw-reverts assertion below. + [Fact] + public async Task Raw_realm_revert_reverts_raw_node_only() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw); + nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); + var now = DateTime.UtcNow; + nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw); + nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns); + var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!; + var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!; + + raw.Value = 42; // optimistic write to the RAW node + + nm.RevertOptimisticWriteIfNeeded( + "A/B/C/D", outcome: new NodeWriteOutcome(false, "device rejected"), + optimisticValue: 42, priorValue: 10, priorStatus: StatusCodes.Good, clientUserId: "op", + realm: AddressSpaceRealm.Raw); + + raw.Value.ShouldBe(10); // raw reverted (would stay 42 if the realm were dropped to Uns) + uns.Value.ShouldBe(10); // UNS node untouched await host.DisposeAsync(); } @@ -143,8 +207,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); - nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; var audit = nm.BuildWriteFailureAuditEvent( @@ -174,8 +238,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); - nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; var audit = nm.BuildWriteFailureAuditEvent( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ActorNodeWriteGatewayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ActorNodeWriteGatewayTests.cs index d70484bc..c168cf88 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ActorNodeWriteGatewayTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ActorNodeWriteGatewayTests.cs @@ -1,6 +1,7 @@ using Akka.Actor; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Xunit; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; @@ -24,7 +25,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase var probe = CreateTestProbe(); var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance); - var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None); + var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None); var routed = probe.ExpectMsg(TimeSpan.FromSeconds(5)); routed.NodeId.ShouldBe("eq-1/speed"); @@ -43,7 +44,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase var probe = CreateTestProbe(); var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance); - var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None); + var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None); probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new DriverHostActor.NodeWriteResult(false, "not primary")); @@ -63,7 +64,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase resolveDriverHost: () => probe.Ref, NullLogger.Instance, askTimeout: TimeSpan.FromMilliseconds(200)); - var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None); + var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None); outcome.Success.ShouldBeFalse(); outcome.Reason.ShouldNotBeNull(); @@ -77,7 +78,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase { var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => null, NullLogger.Instance); - var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None); + var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None); outcome.Success.ShouldBeFalse(); outcome.Reason.ShouldBe("writes unavailable"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs index cd44ab42..c8d5ca33 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs @@ -18,6 +18,18 @@ internal static class DarkAddressSpaceReasons "empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " + "intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests."; + /// Discovered-node INJECTION is dormant in v3 (Wave-B review M1). The v2 path grafted a driver's + /// FixedTree under an equipment folder (equipment bound a driver); v3 retired that binding and discovered + /// raw tags are authored explicitly via the Batch-2 /raw browse-commit flow. HandleDiscoveredNodes + /// hard-short-circuits, so these v2 injection scenarios have no live path to assert; re-migrating injection + /// onto the raw device subtree is a separate follow-up. The dormant guard itself is pinned by + /// DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3. + public const string DiscoveryInjectionDormantV3 = + "v3: discovered-node injection is DORMANT (Wave-B review M1) — equipment no longer binds a driver, so the " + + "v2 FixedTree-under-equipment graft has no live path. Discovered raw tags are authored via the /raw " + + "browse-commit flow. HandleDiscoveredNodes short-circuits (pinned by Discovered_nodes_are_ignored_dormant_in_v3); " + + "re-migrating injection onto the raw device subtree is a separate follow-up."; + /// Equipment↔device host binding is architecturally retired in v3. public const string EquipmentDeviceBindingRetired = "v3: equipment no longer binds a driver/device (EquipmentNode has no DriverInstanceId/DeviceId/" + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs new file mode 100644 index 00000000..fe2cc5ce --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// v3 Batch 4 (Wave-B review recommendation) — the artifact-decode seam +/// () and the live compose seam +/// () must produce BYTE-IDENTICAL Raw and UNS node +/// sets for the same seeded config. This is the guard that a future artifact-decode drift can't silently +/// diverge the deployed dual-namespace address space from what was authored/validated. The existing +/// DeploymentArtifactEquipRefParityTests covers {{equip}} script-path parity; this covers the +/// RawContainers / RawTags / UnsReferenceVariables sets (RawPaths, UNS NodeIds, Organizes backing paths, +/// writable + historian + array shape). +/// +public sealed class DeploymentArtifactRawUnsParityTests +{ + [Fact] + public void Raw_and_uns_node_sets_are_byte_parity_between_compose_and_artifact_decode() + { + // Entity-side config: folder → driver → device → group → 2 tags (one historized+writable+override, + // one plain read-only), an area/line/equipment, and a UNS reference with a display-name override. + var folder = new RawFolder { RawFolderId = "fld-1", ClusterId = "c1", Name = "Cell1", ParentRawFolderId = null }; + var driver = new DriverInstance { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "fld-1" }; + var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" }; + var group = new TagGroup { TagGroupId = "grp-1", DeviceId = "dev-1", Name = "Fast", ParentTagGroupId = null }; + var speed = new Tag + { + TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", + AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}", + }; + var run = new Tag + { + TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean", + AccessLevel = TagAccessLevel.Read, TagConfig = "{}", + }; + var area = new UnsArea { UnsAreaId = "a1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" }; + var equip = new Equipment { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" }; + var reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" }; + + var composed = AddressSpaceComposer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), + unsTagReferences: new[] { reference }, tags: new[] { speed, run }, + rawFolders: new[] { folder }, devices: new[] { device }, tagGroups: new[] { group }); + + // Artifact JSON — the same shape ConfigComposer serializes (enums numeric: ReadWrite = 1, Read = 0). + var blob = JsonSerializer.SerializeToUtf8Bytes(new + { + RawFolders = new[] { new { RawFolderId = "fld-1", ParentRawFolderId = (string?)null, Name = "Cell1", ClusterId = "c1" } }, + DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "fld-1", ClusterId = "c1" } }, + Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } }, + TagGroups = new[] { new { TagGroupId = "grp-1", ParentTagGroupId = (string?)null, DeviceId = "dev-1", Name = "Fast" } }, + Tags = new object[] + { + new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" }, + new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" }, + }, + UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } }, + UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } }, + Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } }, + UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" } }, + }); + + var decoded = DeploymentArtifact.ParseComposition(blob); + + // Sanity: the sets are non-empty (both raw tags + the projected UNS variable materialised). + composed.RawTags.Count.ShouldBe(2); + composed.UnsReferenceVariables.Count.ShouldBe(1); + composed.UnsReferenceVariables[0].NodeId.ShouldBe("filling/line1/station1/MotorSpeed"); + composed.UnsReferenceVariables[0].BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed"); + + // Byte-parity: the three dual-namespace node sets are record-equal between the two seams. + decoded.RawContainers.ShouldBe(composed.RawContainers); + decoded.RawTags.ShouldBe(composed.RawTags); + decoded.UnsReferenceVariables.ShouldBe(composed.UnsReferenceVariables); + } +} 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 33509853..bdbeab4c 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 @@ -108,7 +108,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase /// — proving the live value routed end-to-end and (in production) /// overwrote the BadWaitingForInitialData seed. /// - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good() { var db = NewInMemoryDbFactory(); @@ -154,7 +154,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase /// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL /// WriteValues Good there (the routing map was rebuilt, not left empty by the Clear()). /// - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Discovered_node_and_value_survive_a_redeploy() { var db = NewInMemoryDbFactory(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs index 085fee91..2054eb51 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs @@ -49,12 +49,39 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc); + /// v3 Batch 4 (review M1) — the ONE live dormancy pin: discovered-node injection is short-circuited. + /// A driver reports a FixedTree via , but the host must + /// NOT materialise anything (no reaches the publish + /// side) — discovered raw tags are authored via the /raw browse-commit flow, not injected at runtime. + /// The skipped v2 injection scenarios below are the counterpart of this guard. + [Fact] + public void Discovered_nodes_are_ignored_dormant_in_v3() + { + var db = NewInMemoryDbFactory(); + var factory = new SubscribingDriverFactory("Modbus"); + var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, + (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); + var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); + + actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] + { + new DiscoveredNode( + FolderPathSegments: new[] { "FOCAS", "Identity" }, + BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", + DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, + Writable: false, IsHistorized: false), + })); + + // Dormant: no discovered nodes are materialised (the guard short-circuits before any route/materialise). + publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + /// A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the /// bound equipment: (a) the publish side receives /// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the /// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the /// live-value routing map was extended). - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription() { var db = NewInMemoryDbFactory(); @@ -121,7 +148,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// rooted at its NodeId and the driver /// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with /// "no equipment/authored tags". - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes() { var db = NewInMemoryDbFactory(); @@ -198,7 +225,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref /// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1" /// vs stored "h1" wrinkle proves the SHARED DeviceConfigIntent.NormalizeHost match. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment() { var db = NewInMemoryDbFactory(); @@ -264,7 +291,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here /// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only /// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash). - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft() { var db = NewInMemoryDbFactory(); @@ -305,7 +332,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// logged at Debug, which the suite's loglevel = WARNING HOCON suppresses at source, so EventFilter /// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on /// pass 1; pass 2's matched routing is short-circuited by PlansRoutingEqual.) - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet() { var db = NewInMemoryDbFactory(); @@ -378,7 +405,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// Dedup: a discovered node whose FullReference equals an authored equipment tag's /// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs /// are materialised. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Discovered_node_shadowing_an_authored_ref_is_not_injected() { var db = NewInMemoryDbFactory(); @@ -413,7 +440,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to /// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising /// FixedTree still converges. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does() { var db = NewInMemoryDbFactory(); @@ -469,7 +496,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached /// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's /// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Discovered_nodes_survive_a_redeploy_rebuild() { var db = NewInMemoryDbFactory(); @@ -536,7 +563,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a /// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell /// for the now-unresolved driver. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Discovered_nodes_dropped_when_equipment_no_longer_resolves() { var db = NewInMemoryDbFactory(); @@ -578,7 +605,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// drops the entry. After the redeploy NO is /// re-told. (Complements , which /// covers the Count==0 branch; this covers the rebind/StartsWith branch.) - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment() { var db = NewInMemoryDbFactory(); @@ -627,7 +654,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks /// discovery) AND a fresh re-grafts the FixedTree /// under the new equipment EQ-2. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Config_unchanged_rebind_re_triggers_discovery_on_the_child() { var db = NewInMemoryDbFactory(); @@ -706,7 +733,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// guard — so they would NOT catch a future regression that sets droppedAny on a non-drop path). The /// regression is observable here: a spurious trigger would advance DiscoverCount past the single Once /// pass. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void No_drop_redeploy_does_not_re_trigger_discovery() { var db = NewInMemoryDbFactory(); @@ -757,7 +784,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// SubscribeAsync — no de-dup in ): the count rises by EXACTLY 1 across the /// redeploy and the final subscribed set is the union. (Pre-task: the bulk loop ALSO sent the authored-only /// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".) - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription() { var db = NewInMemoryDbFactory(); @@ -823,7 +850,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// via SubscribeCount (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also /// gets a — a different message type the non-discovery /// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback() { var db = NewInMemoryDbFactory(); @@ -885,7 +912,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// authored-only per redeploy (the re-inject tail /// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's /// send. Observed via SubscribeCount (+1) and the subscribed set ("40001" only). - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription() { var db = NewInMemoryDbFactory(); @@ -934,7 +961,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// the Connected handler routes to Unsubscribe (dropping the stale FixedTree handle) — NOT a subscribe. /// Proven by SubscribeCount staying FLAT across the redeploy (no spurious subscribe), closing the /// SubscribeCount-proxy blind spot for the empty-set fallback. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing() { var db = NewInMemoryDbFactory(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs index cce53094..24f10784 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs @@ -6,6 +6,7 @@ using Akka.Cluster.Tools.PublishSubscribe; using Akka.TestKit; using Microsoft.EntityFrameworkCore; using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; @@ -51,7 +52,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref); var result = asker.ExpectMsg(Timeout); result.Success.ShouldBeFalse(); @@ -75,7 +76,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase AwaitAssert(() => { var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref); var res = asker.ExpectMsg(Timeout); res.Reason.ShouldNotBe("not primary"); res.Reason.ShouldNotBe("not primary (role unknown)"); @@ -100,7 +101,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase AwaitAssert(() => { var asker1 = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0), asker1.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0, AddressSpaceRealm.Uns), asker1.Ref); var res = asker1.ExpectMsg(Timeout); res.Reason.ShouldNotBe("not primary"); res.Success.ShouldBeTrue(res.Reason); @@ -109,7 +110,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase // Secondary snapshot ⇒ denied with the steady-state reason. TellRole(actor, RedundancyRole.Secondary); var asker2 = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0), asker2.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0, AddressSpaceRealm.Uns), asker2.Ref); var denied = asker2.ExpectMsg(Timeout); denied.Success.ShouldBeFalse(); denied.Reason.ShouldBe("not primary"); @@ -126,7 +127,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0, AddressSpaceRealm.Uns), asker.Ref); asker.ExpectMsg(Timeout).Success.ShouldBeFalse(); AwaitAssert(() => diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorProbeResultDropTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorProbeResultDropTests.cs index a9c7df56..6d5a211f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorProbeResultDropTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorProbeResultDropTests.cs @@ -3,6 +3,7 @@ using Akka.Cluster.Tools.PublishSubscribe; using Akka.Event; using Microsoft.EntityFrameworkCore; using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; @@ -87,7 +88,7 @@ public sealed class DriverHostActorProbeResultDropTests : RuntimeActorTestBase // drained its mailbox past the OpcUaProbeResult message, so any dead-letter from an unhandled // OpcUaProbeResult would already be on the EventStream before we assert. var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0, AddressSpaceRealm.Uns), asker.Ref); asker.ExpectMsg(Timeout); // Assert no dead-letter carrying an OpcUaProbeResult was published. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs index 3f9c1527..df53cfa6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs @@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; @@ -56,7 +57,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase var asker = CreateTestProbe(); // The node manager passes the FULL ns-qualified id; the host must normalise + resolve it. - actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref); asker.ExpectMsg(Timeout).Success.ShouldBeTrue(); AwaitAssert(() => @@ -79,7 +80,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase var actor = SpawnHostAndApply(db, deploymentId, recorder); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref); asker.ExpectMsg(Timeout).Success.ShouldBeTrue(); AwaitAssert(() => @@ -110,7 +111,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase CorrelationId.NewId())); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref); var result = asker.ExpectMsg(Timeout); result.Success.ShouldBeFalse(); @@ -129,7 +130,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase var actor = SpawnHostAndApply(db, deploymentId, recorder); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0, AddressSpaceRealm.Uns), asker.Ref); var result = asker.ExpectMsg(Timeout); result.Success.ShouldBeFalse(); @@ -149,7 +150,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase localRoles: new HashSet { "driver" })); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref); var result = asker.ExpectMsg(TimeSpan.FromSeconds(2)); result.Success.ShouldBeFalse(); @@ -157,6 +158,46 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase result.Reason!.ShouldContain("stale"); } + /// + /// Review H1 regression — a raw RawPath and a UNS path that share a BARE s= id but back + /// DIFFERENT driver instances must each route to their OWN driver. Seeds raw tag A/B/C/D on + /// drv-1 and a UnsTagReference whose UNS NodeId is ALSO A/B/C/D (Area A / Line B / Equip C + /// / effective name D) but which backs a raw tag X/Y/Z/W on drv-2. A bare-only routing key + /// would collide (last-writer-wins → wrong device); the (realm, bareId) key keeps them distinct. + /// + [Fact] + public void Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm() + { + var db = NewInMemoryDbFactory(); + var factory = new PerInstanceRecordingDriverFactory("Modbus"); + var deploymentId = SeedCollisionDeployment(db, RevA); + + var actor = SpawnHostAndApply(db, deploymentId, factory); + + // Write to the RAW node A/B/C/D → drv-1, forwarded by the raw tag's RawPath A/B/C/D. + var asker1 = CreateTestProbe(); + actor.Tell(new DriverHostActor.RouteNodeWrite("ns=2;s=A/B/C/D", 111.0, AddressSpaceRealm.Raw), asker1.Ref); + asker1.ExpectMsg(Timeout).Success.ShouldBeTrue(); + + // Write to the UNS node A/B/C/D → drv-2, forwarded by the BACKING raw tag's RawPath X/Y/Z/W. + var asker2 = CreateTestProbe(); + actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=A/B/C/D", 222.0, AddressSpaceRealm.Uns), asker2.Ref); + asker2.ExpectMsg(Timeout).Success.ShouldBeTrue(); + + AwaitAssert(() => + { + // drv-1 got ONLY the raw write (A/B/C/D, 111); drv-2 got ONLY the UNS-routed write (X/Y/Z/W, 222). + var w1 = factory.WritesFor("drv-1"); + var w2 = factory.WritesFor("drv-2"); + w1.Count.ShouldBe(1); + w1[0].FullReference.ShouldBe("A/B/C/D"); + w1[0].Value.ShouldBe(111.0); + w2.Count.ShouldBe(1); + w2[0].FullReference.ShouldBe("X/Y/Z/W"); // the backing RawPath, NOT the shared bare id + w2[0].Value.ShouldBe(222.0); + }, duration: Timeout); + } + /// Spawns the host with the recording driver factory, dispatches the deployment, and waits for /// the Applied ACK so the reverse-map build in PushDesiredSubscriptions has completed before routing a /// write. No OPC UA / mux probes are wired — the write path doesn't depend on the publish actor. @@ -214,6 +255,52 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase return id; } + /// Seeds the review-H1 collision config: raw tag A/B/C/D on drv-1, and a UnsTagReference + /// (Area A / Line B / Equip C, effective name D → UNS NodeId A/B/C/D) backing raw tag X/Y/Z/W + /// on drv-2. Both drivers ENABLED (Modbus) so both children spawn. + private static DeploymentId SeedCollisionDeployment(IDbContextFactory db, RevisionHash rev) + { + var artifact = JsonSerializer.SerializeToUtf8Bytes(new + { + RawFolders = new[] + { + new { RawFolderId = "rf-a", ParentRawFolderId = (string?)null, Name = "A", ClusterId = "c1" }, + new { RawFolderId = "rf-x", ParentRawFolderId = (string?)null, Name = "X", ClusterId = "c1" }, + }, + DriverInstances = new[] + { + new { DriverInstanceId = "drv-1", RawFolderId = "rf-a", Name = "B", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true }, + new { DriverInstanceId = "drv-2", RawFolderId = "rf-x", Name = "Y", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true }, + }, + Devices = new[] + { + new { DeviceId = "dev-c", DriverInstanceId = "drv-1", Name = "C", DeviceConfig = "{}" }, + new { DeviceId = "dev-z", DriverInstanceId = "drv-2", Name = "Z", DeviceConfig = "{}" }, + }, + TagGroups = Array.Empty(), + Tags = new[] + { + new { TagId = "t-d", DeviceId = "dev-c", TagGroupId = (string?)null, Name = "D", DataType = "Double", AccessLevel = 1, TagConfig = "{}" }, + new { TagId = "t-w", DeviceId = "dev-z", TagGroupId = (string?)null, Name = "W", DataType = "Double", AccessLevel = 1, TagConfig = "{}" }, + }, + UnsAreas = new[] { new { UnsAreaId = "a1", Name = "A", ClusterId = "c1" } }, + UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "B" } }, + Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "C", MachineCode = "M1" } }, + // Effective name "D" (override) → UNS NodeId A/B/C/D, backing raw tag t-w (X/Y/Z/W) on drv-2. + UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-w", DisplayNameOverride = "D" } }, + }); + + var id = DeploymentId.NewId(); + using var ctx = db.CreateDbContext(); + ctx.Deployments.Add(new Deployment + { + DeploymentId = id.Value, RevisionHash = rev.Value, Status = DeploymentStatus.Sealed, + CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, + }); + ctx.SaveChanges(); + return id; + } + /// An whose CreateDbContext always throws — drives the host /// into the Stale path. private sealed class ThrowingDbFactory : IDbContextFactory @@ -223,6 +310,41 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase throw new InvalidOperationException("config DB unreachable (test stub)"); } + /// Factory producing a SEPARATE per driver instance id, so a test + /// with two drivers can assert which instance received a given write (used by the H1 collision test). + private sealed class PerInstanceRecordingDriverFactory : IDriverFactory + { + private readonly string _supportedType; + private readonly Dictionary _byId = new(StringComparer.Ordinal); + private readonly object _lock = new(); + public PerInstanceRecordingDriverFactory(string supportedType) { _supportedType = supportedType; } + + /// The writes the given driver instance received. + public IReadOnlyList WritesFor(string driverInstanceId) + { + lock (_lock) return _byId.TryGetValue(driverInstanceId, out var d) ? d.Writes : Array.Empty(); + } + + /// + public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) + { + if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null; + lock (_lock) + { + if (!_byId.TryGetValue(driverInstanceId, out var d)) + { + d = new RecordingDriver(); + d.Bind(driverInstanceId, driverType); + _byId[driverInstanceId] = d; + } + return d; + } + } + + /// + public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; + } + /// Factory producing a single for the supported type. private sealed class RecordingDriverFactory : IDriverFactory { 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 bf3fe3a4..a38b2319 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 @@ -51,7 +51,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase NodeId: "ns=2;s=tag-1", Value: 42, Quality: OpcUaQuality.Good, - TimestampUtc: DateTime.UtcNow)); + TimestampUtc: DateTime.UtcNow, Realm: AddressSpaceRealm.Uns)); AwaitAssertion(() => { 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 1f9e0ba6..3f73107a 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 @@ -20,8 +20,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase public void Accepts_message_contracts_without_pinned_dispatcher_in_tests() { var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests()); - actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow)); - actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow)); + actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns)); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240)); @@ -43,8 +43,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase var sink = new RecordingSink(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); - actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow)); - actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow)); + actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns)); + actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns)); AwaitAssert(() => { @@ -64,7 +64,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); actor.Tell(new OpcUaPublishActor.AlarmStateUpdate( - "ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow)); + "ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns)); AwaitAssert(() => { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs index 2bd182bd..7fb9d2f4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs @@ -138,7 +138,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase } /// Activation path: with the alarm loaded, pushing a value above the threshold drives an - /// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true) and an + /// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true, Realm: AddressSpaceRealm.Uns) and an /// AlarmTransitionEvent("Activated") on the alerts topic. [Fact] public void Dependency_change_above_threshold_activates_alarm() @@ -180,7 +180,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase } /// Clear path: after activating, pushing a value below the threshold drives Active→Inactive - /// — AlarmStateUpdate(Active=false) + AlarmTransitionEvent("Cleared"). + /// — AlarmStateUpdate(Active=false, Realm: AddressSpaceRealm.Uns) + AlarmTransitionEvent("Cleared"). [Fact] public void Dependency_change_below_threshold_clears_alarm() { @@ -279,7 +279,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase /// Command path: an AlarmCommand("Acknowledge") for an alarm this host owns (and that is /// currently active+unacknowledged) drives the engine's AcknowledgeAsync — observed via the resulting - /// AlarmStateUpdate(Acknowledged=true) and an AlarmTransitionEvent("Acknowledged") on the alerts topic + /// AlarmStateUpdate(Acknowledged=true, Realm: AddressSpaceRealm.Uns) and an AlarmTransitionEvent("Acknowledged") on the alerts topic /// carrying the command's User (the user threads through AcknowledgeAsync → LastAckUser → evt.User). [Fact] public void AlarmCommand_acknowledge_drives_engine_with_mapped_args() @@ -573,7 +573,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase /// Inbound command ungated by role (T1): the alerts-publish gate must NOT affect inbound /// command processing. Under a Secondary role, an AlarmCommand("Acknowledge") for an owned, active - /// alarm still drives the engine — observed via the resulting AlarmStateUpdate(Acknowledged=true) + /// alarm still drives the engine — observed via the resulting AlarmStateUpdate(Acknowledged=true, Realm: AddressSpaceRealm.Uns) /// (the OPC UA node write is ungated so the secondary's engine state + address space stay consistent). [Fact] public void Inbound_AlarmCommand_is_processed_regardless_of_role() From 8ebc712effde999a382601653abd906cdf62bd95 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 12:07:11 -0400 Subject: [PATCH 07/14] =?UTF-8?q?feat(v3-batch4-wp4):=20multi-notifier=20n?= =?UTF-8?q?ative=20alarms=20(single=20ReportEvent=20=E2=86=92=20raw=20+=20?= =?UTF-8?q?equipment=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") { } } From 90e52a4415fae007f988fd2669100d71f1f4fb88 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 12:10:01 -0400 Subject: [PATCH 08/14] fix(v3-batch4): thread ReferencingEquipmentPaths into native AlarmTransitionEvent Closes the WP3/WP4 boundary seam WP4 flagged: ForwardNativeAlarm (DriverHostActor, a WP3-owned file WP4 could not edit) built the AlarmTransitionEvent without the referencing-equipment paths, so the /alerts equipment-list chips WP4 added never populated in production (would fail live-gate leg 5). The RawTagPlan already carries ReferencingEquipmentPaths (the Area/Line/Equipment UNS folder paths); this rides them onto the per-condition alarm meta and into every transition. Coordinator-owned 1-file change (WP3 merged, no parallel agent owns this file this wave). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../Drivers/DriverHostActor.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 7723c662..aa679d8f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -171,7 +171,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// TagConfig.alarm.historizeToAveva; it is threaded onto the transition so the /// HistorianAdapterActor's is not false gate suppresses the durable AVEVA row only on an /// explicit false (mirroring the scripted-alarm opt-out). - private readonly Dictionary _alarmMetaByNodeId = + private readonly Dictionary ReferencingEquipmentPaths)> _alarmMetaByNodeId = new(StringComparer.Ordinal); /// Derives a full Part 9 condition snapshot from each native alarm transition delta, @@ -1048,7 +1048,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers if (!serviceAlertsAsPrimary) continue; var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m) - ? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null); + ? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null, + ReferencingEquipmentPaths: (IReadOnlyList)Array.Empty()); _mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, new AlarmTransitionEvent( AlarmId: nodeId, EquipmentPath: meta.EquipmentId, @@ -1068,7 +1069,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // historize). The HistorianAdapterActor gate (historizeToAveva is not false) historizes null + // true and suppresses the durable AVEVA row only on an explicit false — the same posture as the // scripted-alarm opt-out. null here rides through unchanged (the gate treats it as default-on). - HistorizeToAveva: meta.HistorizeToAveva))); + HistorizeToAveva: meta.HistorizeToAveva, + // WP4: the Area/Line/Equipment UNS paths that reference this raw condition — the /alerts row + // renders them as the equipment list (empty for a raw condition no equipment references yet). + ReferencingEquipmentPaths: meta.ReferencingEquipmentPaths))); } } @@ -1573,8 +1577,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // Inverse 1:1 map for the inbound native-condition ack path (keyed by BARE condition NodeId). _driverRefByAlarmNodeId[t.NodeId] = key; // Per-condition metadata for the alerts fan-out. WP4 refines /alerts identity to the referencing - // equipment paths; WP3 keys the display off the RawPath. - _alarmMetaByNodeId[t.NodeId] = (t.NodeId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva); + // equipment paths; WP3 keys the display off the RawPath. The referencing-equipment paths (the + // Area/Line/Equipment UNS folder paths carried on the RawTagPlan) ride onto every transition so + // the /alerts row lists which equipment reference this raw condition. + _alarmMetaByNodeId[t.NodeId] = (t.NodeId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva, t.ReferencingEquipmentPaths); continue; } From 3cf3576c758714b80bb2da832e14a5b06c3805a0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 12:52:34 -0400 Subject: [PATCH 09/14] =?UTF-8?q?fix(v3-batch4-wp4):=20alarm=20fan-out=20h?= =?UTF-8?q?ardening=20=E2=80=94=20event-delivery=20test,=20resolution-fail?= =?UTF-8?q?ure=20meter,=20teardown=20guards=20(Wave=20C=20review=20M1/M2/M?= =?UTF-8?q?3/L1/L3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 — over-the-wire event-delivery proof (new NativeAlarmMultiNotifierEventDeliveryTests in OpcUaServer.IntegrationTests): boots the real server, wires one condition to two equipment folders, fires ONE transition, and asserts a Server-object subscriber gets EXACTLY ONE event (the shared-InstanceStateSnapshot queue dedup), plus overlapping Server + equipment-folder subscribers each get exactly one copy. Captures via the subscription FastEventCallback keyed by ClientHandle (the per-item Notification/DequeueEvents path delivers nothing for these conditions in the SDK client — the working capture is the fast callback). M2 — resolution-failure signal + path-agreement tripwire (AddressSpaceApplier): when a native alarm HAS ReferencingEquipmentPaths but NONE resolve to an equipment folder, count it as a failed node (degraded-apply meter) + LogWarning instead of a silent skip; documented the DisplayName==Name coupling as a binding invariant. Tests: Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier (real composer output → applier inversion) + ..._unresolvable_referencing_equipment_counts_as_failed. M3 — WireAlarmNotifiers is now a RECONCILE (unwires a folder dropped from the supplied set, bidirectionally) so a future surgical ChangedRawTags path can't leave a de-referenced equipment receiving the alarm; binding guard comment added on the classifier's ChangedRawTags→Rebuild branch. Test: WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set. L1 — MaterialiseAlarmCondition's kind-swap drop-and-recreate now calls UnwireAlarmNotifiers(conditionKey) before discarding the old instance (teardown symmetry). L3 — BuildEquipmentIdByFolderPath LogWarnings on a duplicate Area/Line/Name collision (defense-in-depth; UNS uniqueness prevents it). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../AddressSpaceApplier.cs | 45 ++- .../AddressSpaceChangeClassifier.cs | 8 + .../OtOpcUaNodeManager.cs | 32 ++ ...iveAlarmMultiNotifierEventDeliveryTests.cs | 282 ++++++++++++++++++ .../AddressSpaceApplierRawUnsTests.cs | 84 ++++++ .../NodeManagerMultiNotifierAlarmTests.cs | 22 ++ 6 files changed, 465 insertions(+), 8 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmMultiNotifierEventDeliveryTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 6c6caea9..2c6b8d41 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -716,8 +716,21 @@ public sealed class AddressSpaceApplier .Select(id => id!) .Distinct(StringComparer.Ordinal) .ToList(); - if (equipFolderNodeIds.Count > 0 && - !SafeWireAlarmNotifiers(t.NodeId, AddressSpaceRealm.Raw, equipFolderNodeIds, AddressSpaceRealm.Uns)) + if (equipFolderNodeIds.Count == 0) + { + // Wave C review M2: the alarm HAS referencing equipment but NONE of its paths resolved + // to an EquipmentId folder — the native fan-out to those equipment is silently absent. + // Surface it (Warning + a failed-node tally so the degraded-apply meter fires) instead + // of swallowing zero operator signal. Today this only happens on a broken UNS ancestry / + // an invalid segment (the composer would have dropped the equipment too); the latent + // risk is a future editable Area/Line display-name feature breaking the path↔id coupling + // (see BuildEquipmentIdByFolderPath + AddressSpaceComposerPathParityTests). + _logger.LogWarning( + "AddressSpaceApplier: native alarm {Node} has {Count} referencing equipment path(s) but NONE resolved to an equipment folder — the alarm's multi-notifier fan-out to those equipment is absent (paths={Paths})", + t.NodeId, t.ReferencingEquipmentPaths.Count, string.Join(", ", t.ReferencingEquipmentPaths)); + failed++; + } + else if (!SafeWireAlarmNotifiers(t.NodeId, AddressSpaceRealm.Raw, equipFolderNodeIds, AddressSpaceRealm.Uns)) { failed++; } @@ -1082,13 +1095,17 @@ public sealed class AddressSpaceApplier /// 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. + /// construction EXACTLY — path↔id coupling invariant: the composer builds the path from the + /// Area/Line/Equipment Name; this inverts it via the projected DisplayName, which the + /// composer sets to that same Name at every level (verified by + /// AddressSpaceComposerPathParityTests). A future editable Area/Line display-name feature that + /// lets DisplayName != Name would silently break this inversion — that test is the tripwire. + /// 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) + private IReadOnlyDictionary BuildEquipmentIdByFolderPath(AddressSpaceComposition composition) { var areaName = composition.UnsAreas .GroupBy(a => a.UnsAreaId, StringComparer.Ordinal) @@ -1103,8 +1120,20 @@ public sealed class AddressSpaceApplier 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 */ } + string path; + try { path = V3NodeIds.Uns(aName, line.DisplayName, e.DisplayName); } + catch (ArgumentException) { continue; /* invalid segment — dropped, mirroring the composer */ } + // Wave C review L3: two equipment sharing an identical Area/Line/Name collapse to one id + // (last-write-wins). UNS uniqueness prevents this, so it is defense-in-depth: warn (mirroring the + // composer's own last-write-wins spot) rather than silently pick one — a collision here would send + // the alarm's notifier to the wrong equipment folder. + if (map.TryGetValue(path, out var existing) && !string.Equals(existing, e.EquipmentId, StringComparison.Ordinal)) + { + _logger.LogWarning( + "AddressSpaceApplier: two equipment share the UNS folder path {Path} ({Existing} vs {New}) — the native-alarm notifier map collapses them (last-write-wins); UNS uniqueness should prevent this", + path, existing, e.EquipmentId); + } + map[path] = e.EquipmentId; } return map; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs index 8a05bc74..52082009 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs @@ -58,6 +58,14 @@ public static class AddressSpaceChangeClassifier // display-name-override) route to Rebuild as the safe default — WP3's applier owns any surgical // in-place refinement for them. Evaluated BEFORE the add/remove split so a non-surgical change // alongside pure adds still rebuilds. + // + // WP4 BINDING GUARD (Wave C review M3): ChangedRawTags → Rebuild is what keeps native-alarm notifier + // wiring correct today. A native alarm's set of referencing equipment lives in + // RawTagPlan.ReferencingEquipmentPaths (part of its record equality), so ADDING/DROPPING a reference + // makes the raw tag a ChangedRawTag → full rebuild → OtOpcUaNodeManager clears + re-wires the notifiers + // cleanly. ANY future surgical (non-rebuild) ChangedRawTags path MUST also re-wire/unwire the alarm + // notifiers (WireAlarmNotifiers is a reconcile — pass the tag's COMPLETE current referencing set — plus + // an explicit unwire on removal), else a de-referenced equipment keeps receiving the alarm's events. if (plan.ChangedEquipment.Count > 0 || plan.ChangedAlarms.Count > 0 || plan.ChangedRawContainers.Count > 0 || diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index fedca5c5..e882ad1e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -762,6 +762,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // type/severity on redeploy) reflects cleanly instead of leaking the old node. if (_alarmConditions.TryRemove(conditionKey, out var existing)) { + // Wave C review L1: unwire the OLD instance's notifier pairs BEFORE discarding it, so no + // referencing equipment folder keeps a dangling inverse-notifier entry to the dropped + // AlarmConditionState. Unreachable today (native=Raw/RawPath, scripted=Uns/ScriptedAlarmId — no + // same-key kind-swap ever carries wired notifiers), but this is the one asymmetric teardown + // path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace. + UnwireAlarmNotifiers(conditionKey); existing.Parent?.RemoveChild(existing); PredefinedNodes?.Remove(existing.NodeId); } @@ -892,6 +898,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List Folders); /// + /// + /// Wave C review M3 — this is a RECONCILE, not additive-only: it wires the supplied set AND unwires any + /// previously-tracked folder whose id is no longer in + /// (bidirectional), so a de-referenced equipment stops receiving the alarm. Today the applier always + /// passes the COMPLETE referencing-equipment set for the condition, and a de-reference arrives as a + /// ChangedRawTags full rebuild (which clears the wiring), so the unwire leg is a no-op on the + /// current paths — but it makes this method correct for a FUTURE surgical ChangedRawTags path + /// (see AddressSpaceChangeClassifier's ChangedRawTags → Rebuild branch, which carries the + /// matching binding guard). + /// public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { @@ -925,6 +941,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 _alarmNotifierWiring[alarmKey] = wiring; } + // The DESIRED notifier-folder keys (ns-qualified) for this condition — the full set the caller + // supplied, regardless of whether each is currently materialised. Reconcile against it below. + var desiredKeys = notifierFolderNodeIds + .Select(id => MapKey(notifierFolderRealm, id)) + .ToHashSet(StringComparer.Ordinal); + + // Reconcile (M3): unwire any previously-wired folder whose id is NO LONGER in the supplied set — a + // genuine de-reference (the equipment dropped its reference to the alarm's raw tag). Matched by the + // folder's ns-qualified NodeId string so a transiently-unmaterialised-but-still-referenced folder + // (id still present) is NOT unwired. Bidirectional so the folder's inverse entry is removed too. + foreach (var wired in wiring.Folders.Where(w => !desiredKeys.Contains(MapKey(w.NodeId))).ToList()) + { + wiring.Alarm.RemoveNotifier(SystemContext, wired, bidirectional: true); + wiring.Folders.Remove(wired); + } + foreach (var folderNodeId in notifierFolderNodeIds) { if (!_folders.TryGetValue(MapKey(notifierFolderRealm, folderNodeId), out var folder)) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmMultiNotifierEventDeliveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmMultiNotifierEventDeliveryTests.cs new file mode 100644 index 00000000..8674ecf2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmMultiNotifierEventDeliveryTests.cs @@ -0,0 +1,282 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// v3 Batch 4 (B4-WP4) — the HEADLINE over-the-wire proof for the multi-notifier native alarm (Wave C +/// review M1): a SINGLE native transition on a condition wired to MULTIPLE notifier roots (its raw +/// device-folder parent + N referencing equipment folders) delivers exactly one event copy to a +/// Server-object event subscriber — the Part 9 shared-InstanceStateSnapshot dedup +/// (MonitoredItem.QueueEventIsEventContainedInQueue), NOT one copy per root. This is the +/// regression guard the design's "duplicate-report fallback rejected" decision rests on, and it is exactly +/// the multi-root topology (2 equipment folders → 2 notifier paths up to Server) where a per-root +/// re-report would surface as double delivery. +/// +/// Boots the real exactly as SubscriptionSurvivalTests does, +/// materialises the raw condition + two referencing equipment folders + the notifier wiring through the +/// production , opens a real client subscription with event monitored +/// items (captured via the subscription , keyed by +/// ClientHandle), fires ONE transition via , +/// and counts the delivered events. Heavy in-process server+client integration — runs in the serial +/// integration pass, not the lightweight unit sweep. +/// +public sealed class NativeAlarmMultiNotifierEventDeliveryTests +{ + private const string ServerUri = "urn:OtOpcUa.MultiNotifierEventDelivery"; + + // The raw device-oriented topology (ns=Raw) + two referencing equipment folders (ns=UNS). + private const string RawDeviceFolder = "Plant/Modbus/dev1"; + private const string RawAlarmPath = "Plant/Modbus/dev1/temp_hi"; + private const string Equip1 = "EQ-filling-line1-station1"; + private const string Equip2 = "EQ-filling-line2-station2"; + private const string AlarmMessage = "over-temperature (multi-notifier test)"; + + // Index of the Message select clause in the event filter (see BuildEventFilter): [0]=EventId, [1]=Message. + private const int MessageFieldIndex = 1; + + /// One native transition on a condition wired to two equipment folders delivers EXACTLY ONE event + /// to a Server-object subscriber (not one per notifier root) — the shared-snapshot queue dedup. + [Fact] + public async Task Single_transition_delivers_exactly_one_event_at_the_Server_object() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multinotif-evt-{Guid.NewGuid():N}"); + var port = AllocateFreePort(); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct); + await using var _ = host; + + var sink = new SdkAddressSpaceSink(server.NodeManager!); + WireSingleConditionToTwoEquipment(sink); + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var collector = new EventCollector(); + var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; + subscription.FastEventCallback = collector.OnEvents; + session.AddSubscription(subscription); + await subscription.CreateAsync(ct); + + var serverItem = AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + // Fire ONE genuine transition (inactive+acked → active+unacked). No ConditionRefresh is issued, so + // the ONLY event is this transition — a per-root re-report would show up as a second copy. + sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw); + + await WaitUntilAsync(() => collector.CountFor(serverItem.ClientHandle) >= 1, TimeSpan.FromSeconds(5)); + // Settle past the publishing interval so any (erroneous) duplicate would have arrived. + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + + collector.CountFor(serverItem.ClientHandle).ShouldBe(1, + "the single condition must deliver exactly ONE event to the Server object despite two notifier " + + "roots (2 equipment folders) — a per-root re-report would deliver 2."); + + await subscription.DeleteAsync(true, ct); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + /// Overlapping subscriptions: event monitored items at the Server object AND an equipment folder + /// each receive EXACTLY ONE copy of the one transition — the multi-root topology does not multiply delivery + /// to any single subscriber, and the equipment-folder subscriber (ns=UNS) sees the native alarm without + /// touching ns=Raw. + [Fact] + public async Task Overlapping_folder_and_server_subscriptions_each_receive_exactly_one_copy() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multinotif-ovl-{Guid.NewGuid():N}"); + var port = AllocateFreePort(); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Overlap", ct); + await using var _ = host; + + var sink = new SdkAddressSpaceSink(server.NodeManager!); + WireSingleConditionToTwoEquipment(sink); + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + unsNs.ShouldBeGreaterThan((ushort)0); + + var collector = new EventCollector(); + var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; + subscription.FastEventCallback = collector.OnEvents; + session.AddSubscription(subscription); + await subscription.CreateAsync(ct); + + var serverItem = AddEventItem(subscription, ObjectIds.Server); + var equipItem = AddEventItem(subscription, new NodeId(Equip1, unsNs)); + await subscription.ApplyChangesAsync(ct); + + sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw); + + await WaitUntilAsync(() => + collector.CountFor(serverItem.ClientHandle) >= 1 && + collector.CountFor(equipItem.ClientHandle) >= 1, + TimeSpan.FromSeconds(5)); + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + + // Each subscriber gets exactly one copy — the shared snapshot is deduped per monitored item; the + // multi-root topology never multiplies delivery. + collector.CountFor(serverItem.ClientHandle).ShouldBe(1, "Server-object subscriber"); + collector.CountFor(equipItem.ClientHandle).ShouldBe(1, "equipment-folder subscriber (ns=UNS) sees the native alarm"); + + await subscription.DeleteAsync(true, ct); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + /// Materialise the raw device folder + the single native condition at its RawPath + two referencing + /// equipment folders (UNS), then wire the condition as an event notifier of both equipment folders. + private static void WireSingleConditionToTwoEquipment(SdkAddressSpaceSink sink) + { + sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); + sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true); + sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns); + sink.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns); + sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); + } + + private static AlarmConditionSnapshot ActiveSnapshot() => + new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, + Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage); + + private static MonitoredItem AddEventItem(Subscription subscription, NodeId source) + { + var item = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = source, + AttributeId = Attributes.EventNotifier, + Filter = BuildEventFilter(), + QueueSize = 100, + SamplingInterval = 0, + }; + subscription.AddItem(item); + return item; + } + + /// An EventFilter selecting [0]=EventId (dedup identity) and [1]=Message (our discriminator). + private static EventFilter BuildEventFilter() + { + var filter = new EventFilter(); + filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId); + filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message); + return filter; + } + + /// Collects events delivered to a subscription's , + /// tallying per monitored-item ClientHandle and filtering to our unique alarm Message (so unrelated server + /// events / refresh brackets never count). + private sealed class EventCollector + { + private readonly object _gate = new(); + private readonly Dictionary _byHandle = new(); + + public void OnEvents(Subscription subscription, EventNotificationList notification, IList stringTable) + { + lock (_gate) + { + foreach (var e in notification.Events) + { + if (e.EventFields.Count > MessageFieldIndex && + e.EventFields[MessageFieldIndex].Value is LocalizedText lt && + lt.Text == AlarmMessage) + { + _byHandle[e.ClientHandle] = _byHandle.GetValueOrDefault(e.ClientHandle) + 1; + } + } + } + } + + public int CountFor(uint clientHandle) + { + lock (_gate) return _byHandle.GetValueOrDefault(clientHandle); + } + } + + private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync( + int port, string pkiRoot, string appUri, CancellationToken ct) + { + var options = new OpcUaApplicationHostOptions + { + ApplicationName = appUri, + ApplicationUri = appUri, + OpcUaPort = port, + PublicHostname = "127.0.0.1", + PkiStoreRoot = pkiRoot, + EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None }, + AutoAcceptUntrustedClientCertificates = true, + }; + var server = new OtOpcUaSdkServer(); + var host = new OpcUaApplicationHost(options, NullLogger.Instance); + await host.StartAsync(server, ct); + return (server, host); + } + + private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) + { + var appConfig = new ApplicationConfiguration + { + ApplicationName = "OtOpcUa.MultiNotifierEventDeliveryClient", + ApplicationUri = $"urn:OtOpcUa.MultiNotifierEventDeliveryClient.{Guid.NewGuid():N}", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, + }; + await appConfig.ValidateAsync(ApplicationType.Client, ct); + appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct); + var endpointConfiguration = EndpointConfiguration.Create(appConfig); + var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); + + return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( + appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false, + sessionName: "MultiNotifierEventDeliveryTests", sessionTimeout: 60_000, + identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct); + } + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(50); + } + condition().ShouldBeTrue("condition not met within timeout"); + } + + private static int AllocateFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static void SafeDelete(string dir) + { + if (Directory.Exists(dir)) + { + try { Directory.Delete(dir, recursive: true); } + catch { /* best-effort */ } + } + } +} 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 c4fc7bf7..01f3969c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.OpcUaServer; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; @@ -152,6 +154,88 @@ public sealed class AddressSpaceApplierRawUnsTests w.FolderNodeIds.ShouldBe(new[] { "EQ-1", "EQ-2" }, ignoreOrder: true); } + /// + /// Wave C review M2 — composer↔applier PATH-AGREEMENT (the tripwire for the latent DisplayName==Name + /// coupling). The composer builds a native alarm's ReferencingEquipmentPaths from the + /// Area/Line/Equipment Names; the applier's BuildEquipmentIdByFolderPath inverts those + /// paths back to the EquipmentId via the projected DisplayName. Feed a REAL composer output + /// (not a hand-built plan) through and assert + /// the notifier wired to exactly the backing equipment's id — proving the two path constructions agree + /// end-to-end. If a future editable Area/Line display-name feature made DisplayName != Name, this + /// inversion (and thus the native-alarm fan-out) would break with no deploy error; this test trips first. + /// + [Fact] + public void Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier() + { + // Real config: a raw alarm tag (Speed) referenced by one equipment (eq-1 under Area "filling" / + // Line "line-1" / Equipment "station-1"). + var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant", + Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}", + }; + var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" }; + var speed = new Tag + { + TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", + }; + var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; + var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" }; + var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" }; + + var composition = AddressSpaceComposer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), + unsTagReferences: new[] { refSpeed }, + tags: new[] { speed }, + rawFolders: new[] { folder }, + devices: new[] { device }, + tagGroups: Array.Empty()); + + // The composer emitted the alarm tag's referencing-equipment path from the Area/Line/Equipment Names. + var alarmTag = composition.RawTags.Single(t => t.Alarm is not null); + alarmTag.ReferencingEquipmentPaths.ShouldBe(new[] { V3NodeIds.Uns("filling", "line-1", "station-1") }); + + // The applier inverts that path back to the EquipmentId folder and wires the notifier there. + var sink = new RealmRecordingSink(); + NewApplier(sink).MaterialiseRawSubtree(composition); + + var w = sink.NotifierWirings.ShouldHaveSingleItem(); + w.AlarmNodeId.ShouldBe(alarmTag.NodeId); + w.FolderNodeIds.ShouldBe(new[] { "eq-1" }); // resolved path → EquipmentId + } + + /// Wave C review M2 — when a native alarm HAS referencing equipment paths but NONE resolve to an + /// equipment folder (broken ancestry / the latent coupling breaking), the applier surfaces it as a failed + /// node (so the degraded-apply meter fires) instead of silently skipping — no notifier is wired, but the + /// condition itself is still materialised. + [Fact] + public void MaterialiseRawSubtree_alarm_with_unresolvable_referencing_equipment_counts_as_failed() + { + var sink = new RealmRecordingSink(); + // No equipment nodes in the composition → the referencing path can't resolve to an EquipmentId folder. + var composition = new AddressSpaceComposition( + Array.Empty(), 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), + ReferencingEquipmentPaths: new[] { "filling/line1/ghost" }), + }, + }; + + var failed = NewApplier(sink).MaterialiseRawSubtree(composition); + + failed.ShouldBeGreaterThanOrEqualTo(1); // resolution failure surfaced (not a silent skip) + sink.NotifierWirings.ShouldBeEmpty(); // nothing wired + sink.Conditions.ShouldHaveSingleItem(); // the condition itself still materialised + } + [Fact] public void MaterialiseUnsReferences_creates_uns_variable_and_organizes_edge_inheriting_historian() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMultiNotifierAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMultiNotifierAlarmTests.cs index 6840da86..a377cca0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMultiNotifierAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMultiNotifierAlarmTests.cs @@ -177,6 +177,28 @@ public sealed class NodeManagerMultiNotifierAlarmTests : IDisposable await host.DisposeAsync(); } + /// Wave C review M3 — is a RECONCILE: re-wiring + /// with a SHRUNK folder set unwires (bidirectionally) the dropped equipment folder, so a de-referenced + /// equipment stops receiving the alarm. (Guards a future surgical ChangedRawTags path; today the shrink + /// arrives as a full rebuild.) + [Trait("Category", "Unit")] + [Fact] + public async Task WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set() + { + var (host, nm) = await BootWithTwoEquipmentAsync(); + nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); + nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2); + + // Re-wire with only Equip1 (Equip2 de-referenced) — reconcile unwires Equip2 bidirectionally. + nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns); + + nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1); + nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1); + nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(0); // no dangling inverse to the condition + + await host.DisposeAsync(); + } + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var host = new OpcUaApplicationHost( From b8208b331275f4f541055945bd6ce4ac2c1249e9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 13:11:34 -0400 Subject: [PATCH 10/14] test+docs(v3-batch4-wp5): 2-node dual-namespace harness tests + address-space docs Tests: - OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs (NEW, over-the-wire, offline-safe): both namespace URIs registered + distinct; Raw + UNS subtrees browse and read; UNS variable Organizes-references its raw node; single-source fan-out parity (identical value/quality/timestamp on both NodeIds); HistoryRead via either NodeId -> GoodNoData under the shared tagname; WriteOperate gate symmetric across both NodeIds. - Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs (extended): full deploy -> persisted-artifact -> ParseComposition round-trip carrying both realms, sealing across the redundant 2-node cluster (redundancy non-interference). In-memory harness, offline. Docs (dual-namespace reality): - CLAUDE.md: new "v3 OPC UA Address Space (Batch 4)" section + Batch-4 testing paragraph. - docs/Uns.md: address-space projection (two namespaces, Organizes edge, effective-name leaf). - docs/Historian.md: dual-registration (both NodeIds -> one tagname); updated CLI examples. - docs/ScriptedAlarms.md + docs/AlarmTracking.md: multi-notifier fan-out, ConditionId=RawPath. - docs/ScriptEditor.md: dual-namespace clarification (script tag-path semantics unchanged). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- CLAUDE.md | 44 ++- docs/AlarmTracking.md | 27 ++ docs/Historian.md | 29 +- docs/ScriptEditor.md | 7 + docs/ScriptedAlarms.md | 25 +- docs/Uns.md | 35 ++ .../EquipmentNamespaceMaterializationTests.cs | 78 ++++- .../DualNamespaceAddressSpaceTests.cs | 326 ++++++++++++++++++ 8 files changed, 555 insertions(+), 16 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 3e7d1313..a424866c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,9 +68,45 @@ shared-lib consumption, or per-project commands — update the **OtOpcUa entry i gRPC session. The gateway owns the COM apartment + STA pump server-side; the driver speaks `MxCommand` / `MxEvent` protos exclusively. -3. **OPC UA Server** — Exposes authored equipment tags as variable nodes. - Galaxy tags are bound by `TagConfig.FullName` (`tag_name.AttributeName`); - reads/writes/subscriptions are translated to that reference for MXAccess. +3. **OPC UA Server** — Exposes the deployed tree under **two OPC UA namespaces** + (see below). Galaxy tags are bound by `TagConfig.FullName` + (`tag_name.AttributeName`); reads/writes/subscriptions are translated to that + reference for MXAccess. + +### v3 OPC UA Address Space (Batch 4): dual namespace + +The server exposes **two OPC UA namespaces** (replacing the single +`https://zb.com/otopcua/ns`), built by `AddressSpaceComposer` / +`AddressSpaceApplier` and served by `OtOpcUaNodeManager` (which registers both +URIs; `V3NodeIds` + `AddressSpaceRealm` are the identity authority): + +| Namespace URI | Realm | Subtree | NodeId `s=` scheme | +|---|---|---|---| +| `https://zb.com/otopcua/raw` | `AddressSpaceRealm.Raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) | +| `https://zb.com/otopcua/uns` | `AddressSpaceRealm.Uns` | the UNS tree (Area → Line → Equipment → signal) | slash-joined **`Area/Line/Equipment/EffectiveName`** | + +- **Single source, fanned to both.** Every device value has exactly one source — + the raw tag's node. A `UnsTagReference` projects that raw tag into an equipment + as a UNS-namespace variable that carries an **`Organizes` reference to its raw + node** and mirrors it. One driver publish for a RawPath fans (in `DriverHostActor`) + to the raw NodeId AND every referencing UNS NodeId with identical + value/quality/timestamp — the mux key stays single (RawPath). +- **Writes via either NodeId** route to the same driver ref under the same + `WriteOperate` gating (the node-manager write gate is realm-qualified and fails + closed); a failed device write reverts both NodeIds via the shared fan-out + (write-outcome self-correction). +- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and + fan via SDK notifiers to the raw device folder AND every referencing equipment + folder — one `ReportEvent`, one Server-object copy; ack/confirm/shelve route on + `ConditionId = RawPath`. +- **Historian dual-registration.** Both NodeIds register the **same** historian + tagname; the mux `HistorizedTagRef` stays single (RawPath). HistoryRead works + through either NodeId. + +The retired `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme and +the single-namespace `EquipmentTags` materialization path are gone. See +`docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` + +`docs/plans/2026-07-15-v3-batch4-address-space-plan.md`, and `docs/Uns.md`. ### Key Concept: Tag Name and FullName @@ -219,6 +255,8 @@ The AdminUI's global **UNS** page (`/uns`) is the single surface for managing th **v3 (Batch 3): UNS Equipment is reference-only.** The equipment **Tags** tab no longer authors or binds tags — it holds `UnsTagReference` rows pointing at raw tags authored in `/raw`. "+ Add reference" opens the `RawTree` in picker mode, **cluster-scoped** (structurally + server-enforced `tag.cluster == equipment.cluster`); rows show effective name / RawPath / inherited DataType+AccessLevel / display-name override. **Effective-name uniqueness** (references + VirtualTags + ScriptedAlarms share the `{EquipmentId}/{EffectiveName}` NodeId space) is enforced at authoring (`IEffectiveNameGuard`) and at the deploy gate (`DraftValidator.UnsEffectiveNameCollision` — catches rename-induced collisions). Scripts use **`ctx.GetTag("{{equip}}/")`** — resolved per-equipment through `UnsTagReference` effective names to the backing RawPath at both compose seams (`AddressSpaceComposer` + `DeploymentArtifact`, via the shared `EquipmentReferenceMap`, byte-parity); an unresolved `` is a deploy error (`EquipReferenceUnresolved`) AND a live Monaco diagnostic (`OTSCRIPT_EQUIPREF`) — editor accepts ⇔ publish accepts. `EquipmentScriptPaths.DeriveEquipmentBase` is deleted (the dot-joint `{{equip}}.X` became the slash-joint `{{equip}}/`). Raw rename warns when a beneath-it tag is historized-without-override / UNS-referenced / named by a script literal (`RawTreeService` substring scan). `ImportEquipmentModal` dropped the `DriverInstanceId` column. See `docs/Uns.md` + `docs/ScriptEditor.md`. +**v3 (Batch 4 = v3.0): dual-namespace address space.** The OPC UA server exposes **two namespaces** — `https://zb.com/otopcua/raw` (Raw device tree, `s=`) + `https://zb.com/otopcua/uns` (UNS tree, `s=///`) — replacing the single `.../ns` and the retired `EquipmentNodeIds` scheme. Every value has ONE source (the raw node's publish); a driver publish for a RawPath fans in `DriverHostActor` to the raw NodeId AND every referencing UNS NodeId with identical value/quality/timestamp, and each UNS variable `Organizes`-references its raw node. Writes route via **either** NodeId to the same driver ref under the same realm-qualified `WriteOperate` gate (failed-write revert works through both); native alarms materialize ONCE at the raw tag (`ConditionId = RawPath`) and fan via SDK `AddNotifier` to the raw device folder + every referencing equipment folder (one `ReportEvent`, one Server-object copy); both NodeIds register the SAME historian tagname (mux `HistorizedTagRef` stays single, keyed by RawPath). Identity authority: `V3NodeIds` + `AddressSpaceRealm` (carried explicitly at every sink seam — never parsed out of the id string). See the "v3 OPC UA Address Space (Batch 4)" section above, `docs/Uns.md`, and `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`. + The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`. ## Scripting / Script Editor diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md index 2216f2dd..9046cda7 100644 --- a/docs/AlarmTracking.md +++ b/docs/AlarmTracking.md @@ -24,6 +24,33 @@ condition — the dedup logic prefers the richer driver-native record because it carries the full operator + raise-time + category metadata that the value-driven path collapses. +## v3 Batch 4 — multi-notifier delivery (raw + equipment folders) + +Under the v3 dual-namespace address space, a native driver alarm is authored on a +**raw tag** (`/raw`) and its Part 9 `AlarmConditionState` materializes **once** at the +raw tag — `ConditionId` = the tag's **RawPath**, parent notifier = the raw device/group +folder (`ns=Raw`). Because equipment references raw tags (v3 reference-only UNS), the +same condition is wired as an **event notifier of every referencing equipment folder** +(`ns=UNS`) via the SDK `AddNotifier` pattern (`OtOpcUaNodeManager.WireAlarmNotifiers`): +`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` + +`EnsureFolderIsEventNotifier(equipFolder)`. + +- A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder, + every referencing equipment folder, and up to the Server object. A subscriber at any one + root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot` + dedup (`MonitoredItem.QueueEvent` → `IsEventContainedInQueue`), **never** one copy per root. + Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break + Server-object dedup + Part 9 ack correlation). +- **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls + `RemoveNotifier(..., bidirectional:true)` on rebuild / subtree-removal / reference-removal, so + inverse-notifier entries never leak across redeploys. +- **Ack/confirm/shelve route on `ConditionId = RawPath`** (never `SourceNodeId`) regardless of + which notifier root the operator subscribed at — an ack issued from an equipment-folder + subscription resolves to the same raw condition. +- The `alerts`-topic `AlarmTransitionEvent` carries the (possibly empty) **list of referencing + equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary + identity RawPath + condition NodeId) with the equipment list as display metadata. + ## Galaxy driver path (driver-native) Restored in PR B.2 of the epic. `GalaxyDriver` implements diff --git a/docs/Historian.md b/docs/Historian.md index 566f82c7..a960e997 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -199,6 +199,15 @@ The server supports all four OPC UA HistoryRead variants: `Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant OPC UA client can discover historized capability from the node's attributes. +> **v3 Batch 4 — dual-namespace registration.** A historized raw tag surfaces as **two** +> variable nodes: the Raw-namespace node (`ns=Raw, s=`) and, for each referencing +> equipment, a UNS-namespace node (`ns=UNS, s=///`). +> **Both NodeIds register the SAME historian tagname** (the raw tag's `FullName` / +> `historianTagname`), so HistoryRead against either NodeId resolves to the same tagname and +> returns the same series. The historization intent is single-sourced — the mux's +> `HistorizedTagRef` set stays keyed by RawPath (no doubles), and continuous historization / +> `EnsureTags` provisioning run once per raw tag regardless of how many equipment reference it. + **Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that owns at least one alarm condition is already an event notifier; the server registers a `sourceName` (the equipment id) for each such folder and maps event history reads to the @@ -355,31 +364,39 @@ for the full alarm-historian routing. The `historyread` command reads historical data from any node. Supply start and end times in ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference. +> **v3 Batch 4 NodeIds.** A historized tag is readable through **either** of its two NodeIds — +> the Raw-namespace node (`s=`, e.g. `Plant/Modbus/dev1/Speed`) or a referencing +> equipment's UNS-namespace node (`s=///`). Both register +> the same historian tagname, so the returned series is identical. The `ns=N` index is assigned +> at connect time per the namespace URI (`https://zb.com/otopcua/raw` / +> `https://zb.com/otopcua/uns`) — resolve it from the server's `NamespaceArray` rather than +> hard-coding it. The examples below show a Raw-namespace read (`ns=2` illustrative). + ```bash -# Raw history for a historized Galaxy tag (last 24 hours by default) +# Raw history for a historized tag via its Raw-namespace NodeId (last 24 hours by default) otopcua-cli historyread \ -u opc.tcp://localhost:4840/OtOpcUa \ - -n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ + -n "ns=2;s=Plant/Modbus/dev1/Speed" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" -# Limit to 100 values +# Same series via a referencing equipment's UNS-namespace NodeId, limited to 100 values otopcua-cli historyread \ -u opc.tcp://localhost:4840/OtOpcUa \ - -n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ + -n "ns=3;s=filling/line1/station1/Speed" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \ --max 100 # 1-hour average aggregate otopcua-cli historyread \ -u opc.tcp://localhost:4840/OtOpcUa \ - -n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ + -n "ns=2;s=Plant/Modbus/dev1/Speed" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \ --aggregate Average --interval 3600000 # Authenticated read (ReadOnly role or higher required) otopcua-cli historyread \ -u opc.tcp://localhost:4840/OtOpcUa \ - -n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ + -n "ns=2;s=Plant/Modbus/dev1/Speed" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \ -U reader -P password ``` diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md index c3047afc..bcbba33d 100644 --- a/docs/ScriptEditor.md +++ b/docs/ScriptEditor.md @@ -212,6 +212,13 @@ The catalog is scoped per Blazor circuit; each call creates and disposes its own `DbContext` via the pooled factory (same pattern as `UnsTreeService`). Results are bounded to 200 entries to keep the completion list responsive on large fleets. +> **v3 Batch 4 (dual namespace) — unchanged for scripts.** A `ctx.GetTag(...)` literal +> still resolves by the tag's `FullName` / **RawPath** — which is precisely the identity +> of the Raw-namespace node (`ns=Raw, s=`). A `{{equip}}/` token resolves +> through the equipment's `UnsTagReference` to that same backing RawPath (see below), so +> scripts key off the single value source regardless of the UNS projection. The dual +> namespace changes only the OPC UA address-space surface, not script tag-path semantics. + ### Literal gate `TryGetTagPathLiteral` identifies the tag-path context by climbing from the diff --git a/docs/ScriptedAlarms.md b/docs/ScriptedAlarms.md index c2a8ab74..30ac89ba 100644 --- a/docs/ScriptedAlarms.md +++ b/docs/ScriptedAlarms.md @@ -127,9 +127,25 @@ object alongside the usual `"FullName"`: (unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9 `AlarmConditionState` under its equipment folder **instead of** a value variable. No EF/schema change is required; the intent rides in the schemaless `TagConfig` -blob and is parsed byte-parity in both the compose (`Phase7Composer`) and deploy +blob and is parsed byte-parity in both the compose (`AddressSpaceComposer`) and deploy (`DeploymentArtifact`) paths. +> **v3 Batch 4 — multi-notifier fan-out.** The `"alarm"` object now rides on a **raw +> tag** authored in `/raw` (referenced into equipment), and the Part 9 condition +> materializes **once at the raw tag** — its `ConditionId` is the tag's **RawPath**, its +> parent notifier is the raw device/group folder. For **each referencing equipment**, the +> node manager (`WireAlarmNotifiers`) wires the SDK `AddNotifier` pattern +> (`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` + +> `EnsureFolderIsEventNotifier(equipFolder)`) so the single condition fans to the raw device +> folder AND every equipment folder. A **single** `ReportEvent` fans to all notifier roots — +> a Server-object subscriber sees **exactly one** copy (the Part 9 shared-snapshot dedup), +> never one-per-root. Teardown is symmetric (`RemoveNotifier(..., bidirectional:true)` on +> rebuild / reference removal) so inverse-notifier entries never leak across redeploys. +> Ack/confirm/shelve route on **`ConditionId = RawPath`** (never `SourceNodeId`), and the +> `/alerts` row lists the referencing equipment paths. See +> [AlarmTracking.md](AlarmTracking.md) and the WP4 section of +> `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`. + ### TagConfig alarm fields | Field | Values | Default | @@ -208,9 +224,10 @@ native alarm transitions identically. Publication to the `alerts` topic is the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm for failover. -The alarm is authored on the `Tags` tab of the equipment page (`/uns/equipment/{id}`) -by editing the tag's raw `TagConfig` JSON to include the `"alarm"` object. No -other configuration is required. +The alarm is authored on the **raw tag** in `/raw` by including the `"alarm"` object in the +tag's `TagConfig` JSON (v3 Batch 4 — equipment no longer authors tags; it references raw +tags). No other configuration is required: any equipment that references the raw tag +automatically receives the alarm at its equipment folder via the multi-notifier fan-out above. ### Native-alarm OPC UA operator operations diff --git a/docs/Uns.md b/docs/Uns.md index 912958b5..5579002e 100644 --- a/docs/Uns.md +++ b/docs/Uns.md @@ -110,6 +110,41 @@ UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md). `GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw tag. There is no separate alias concept or `SystemPlatform`-kind namespace. +### OPC UA address-space projection (v3 Batch 4 — dual namespace) + +> **v3 (Batch 4):** the server now exposes the address space under **two OPC UA +> namespaces** instead of the old single `https://zb.com/otopcua/ns`: +> +> | Namespace URI | Subtree | NodeId `s=` scheme | +> |---|---|---| +> | `https://zb.com/otopcua/raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) | +> | `https://zb.com/otopcua/uns` | the UNS tree (Area → Line → Equipment → signal) | the slash-joined **`Area/Line/Equipment/EffectiveName`** | + +Every device value has **exactly one source** — the raw tag's node in the Raw +namespace. A `UnsTagReference` projects that raw tag into an equipment as a +**UNS-namespace variable** whose NodeId leaf is the reference's **effective name** +(the display-name override else the raw tag's `Name`). The UNS variable does not +bind a driver of its own: it carries an **`Organizes` reference to its backing raw +node** and mirrors it. A single driver publish for a RawPath **fans out** to the +raw NodeId AND every referencing UNS NodeId with identical value / quality / +source-timestamp — the two NodeIds never drift. + +- **Reads / subscriptions** work through either NodeId and return the same data. +- **Writes** route through either NodeId to the same backing driver ref under the + **same `WriteOperate` gating** (a UNS write is neither more nor less privileged + than the raw write it fans from); a failed device write reverts both NodeIds via + the shared fan-out. +- **HistoryRead** works through either NodeId and returns the same series — both + NodeIds register the **same historian tagname** (see [`Historian.md`](Historian.md)). +- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and + fan via SDK notifiers to the raw device folder AND every referencing equipment + folder — see [`ScriptedAlarms.md`](ScriptedAlarms.md) / [`AlarmTracking.md`](AlarmTracking.md). + +Note the two distinct identity strings: the wire **NodeId** is the path +`Area/Line/Equipment/EffectiveName`, while the **effective-name uniqueness key** +above is `{EquipmentId}/{EffectiveName}` (the logical per-equipment collision +space the guards enforce). They are related but not the same string. + ### Virtual tags A virtual tag is bound to an equipment and driven by a **script** (no driver). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs index 5e862830..3811e137 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs @@ -29,9 +29,11 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// real AddressSpaceComposer over the SAME seeded config (loaded back from the deploy DB) and pins /// the Batch-4 dual-tree: the seeded raw tag surfaces as a Raw-realm Variable keyed by its RawPath, and the /// UnsTagReference surfaces as a UNS-realm Variable carrying that RawPath (the Organizes target + fan-out). -/// Note: the composer is what WP1 lights up; the artifact-decode seam -/// (DeploymentArtifact.ParseComposition) is migrated by WP3, so the dual-tree assertion runs through -/// the composer directly rather than the full deploy → artifact round-trip. +/// Note: the composer is what WP1 lights up. WP3 migrated the artifact-decode seam +/// (DeploymentArtifact.ParseComposition) to carry both realms too, so +/// (added in WP5) pins +/// the dual tree through the FULL deploy → persisted-artifact → decode round-trip a driver node applies, and +/// additionally asserts the dual-namespace deploy seals across the redundant 2-node cluster. /// /// /// The OPC UA address-space browse is exercised separately against a real SDK node manager in @@ -142,6 +144,76 @@ public sealed class EquipmentNamespaceMaterializationTests composition.EquipmentTags.ShouldBeEmpty(); } + /// BATCH-4 harness round-trip + redundancy: deploying the seeded dual-namespace config through the + /// REAL deploy pipeline (AdminOperations → ConfigPublishCoordinator → BOTH DriverHostActors) seals on the + /// 2-node cluster, AND the persisted artifact round-trips BOTH realms via + /// — the raw tag as a Raw-realm + /// Variable keyed by its RawPath, and the UnsTagReference as a UNS-realm Variable carrying that RawPath (the + /// Organizes/fan-out backing path). This is the harness-level dual-namespace materialization proof: the + /// second namespace neither breaks redundant sealing nor is lost across artifact serialization. (WP3 migrated + /// the artifact-decode seam to carry both realms, so — unlike the composer-direct test above — this exercises + /// the full deploy → persisted-artifact → decode path a driver node actually applies.) + [Fact] + public async Task Deployed_artifact_round_trips_both_namespaces_and_seals_on_the_cluster() + { + await using var harness = await TwoNodeClusterHarness.StartAsync(); + await harness.SeedDefaultClusterAsync("c1"); + await SeedUnsHierarchyAsync(harness); + + await using var scope = harness.NodeA.Services.CreateAsyncScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + var result = await client.StartDeploymentAsync(createdBy: "alice@test", Ct); + result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, $"Deploy not accepted: {result.Message}"); + var deploymentId = result.DeploymentId!.Value.Value; + + // Redundancy non-interference: the dual-namespace deploy seals after BOTH DriverHostActors apply — the + // second namespace does not break the redundant seal path (the ServiceLevel / primary-gate machinery + // is orthogonal to the address-space namespace count; see FailoverDuringDeploy / PrimaryGateFailover). + var artifact = Array.Empty(); + await WaitForAsync(async () => + { + await using var db = await CreateDbAsync(harness); + var d = await db.Deployments.AsNoTracking() + .FirstOrDefaultAsync(x => x.DeploymentId == deploymentId, Ct); + if (d is { Status: DeploymentStatus.Sealed, ArtifactBlob.Length: > 0 }) + { + artifact = d.ArtifactBlob; + return true; + } + return false; + }, TimeSpan.FromSeconds(20)); + + await using (var db = await CreateDbAsync(harness)) + { + var nodeStates = await db.NodeDeploymentStates.AsNoTracking() + .Where(s => s.DeploymentId == deploymentId) + .ToListAsync(Ct); + nodeStates.Count.ShouldBe(2, "both cluster nodes record a per-node deployment state"); + nodeStates.ShouldAllBe(s => s.Status == NodeDeploymentStatus.Applied); + } + + // Round-trip the PERSISTED artifact through the artifact-decode seam and pin the dual tree. + var composition = DeploymentArtifact.ParseComposition(artifact); + + // Raw subtree: the seeded raw tag survives as a Raw-realm Variable keyed by its RawPath. + var rawTag = composition.RawTags.ShouldHaveSingleItem(); + rawTag.TagId.ShouldBe("tag-speed"); + rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw); + rawTag.NodeId.ShouldBe("Plant/Modbus/dev-1/Speed"); + + // UNS subtree: the UnsTagReference survives as a UNS-realm Variable carrying the backing RawPath (the + // Organizes UNS→Raw target + the fan-out source). + var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem(); + unsVar.Realm.ShouldBe(AddressSpaceRealm.Uns); + unsVar.EquipmentId.ShouldBe(EquipmentId); + unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed"); + unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev-1/Speed"); + + // The retired equipment-namespace tag path stays empty (values flow through raw + UNS now). + composition.EquipmentTags.ShouldBeEmpty(); + } + private static async Task SeedUnsHierarchyAsync(TwoNodeClusterHarness harness) { await using var db = await CreateDbAsync(harness); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs new file mode 100644 index 00000000..dbf9d759 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs @@ -0,0 +1,326 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// v3 Batch 4 (B4-WP5) — the over-the-wire proof of the dual-namespace address space. Boots the real +/// (exactly as SubscriptionSurvivalTests / the multi-notifier +/// alarm test do), materialises a raw device folder + a raw tag Variable (ns=Raw, s=<RawPath>) +/// and an equipment folder + a UNS reference Variable (ns=UNS, s=<Area/Line/Equip/Eff>) through +/// the PRODUCTION , wires the Organizes UNS→Raw edge, then drives a +/// real OPC UA client to assert: +/// +/// BOTH namespace URIs ( + +/// ) are registered + distinct (they replaced the single +/// .../ns). +/// Both subtrees browse + read: the raw Variable at its RawPath NodeId and the UNS Variable at its +/// equipment-path NodeId. +/// The UNS Variable Organizes-references its backing raw node. +/// Single-source fan-out parity: one driver publish (modelled as a +/// to each NodeId with identical value/quality/timestamp) +/// is read back IDENTICALLY on both NodeIds. +/// HistoryRead via EITHER NodeId returns GoodNoData under the shared historian tagname (the +/// NullHistorianDataSource default — the offline parity path). +/// The WriteOperate gate is symmetric across both NodeIds: an anonymous client write is +/// rejected identically on the raw and the UNS NodeId (fail-closed at the role gate — the POSITIVE +/// realm-qualified routing path is unit-covered in DriverHostActorWriteRoutingTests / +/// NodeManagerWriteRevertTests, which drive a role-carrying identity the node manager +/// directly). +/// +/// Heavy in-process server+client integration — runs in the serial integration pass, and is fully +/// offline-safe (no Docker / SQL / gateway; the historian read defaults to the Null source). +/// +public sealed class DualNamespaceAddressSpaceTests +{ + private const string ServerUri = "urn:OtOpcUa.DualNamespaceAddressSpace"; + + // Raw device tree (ns=Raw): Folder → tag Variable, both keyed by RawPath. + private const string RawDeviceFolder = "Plant/Modbus/dev1"; + private const string RawTagPath = "Plant/Modbus/dev1/Speed"; + // UNS equipment tree (ns=UNS): equipment Folder → reference Variable keyed by the equipment path. + private const string EquipFolder = "filling/line1/station1"; + private const string UnsVarPath = "filling/line1/station1/Speed"; + // Both NodeIds register the SAME historian tagname (the raw tag's RawPath). + private const string HistorianTagname = RawTagPath; + + [Fact] + public async Task Both_namespaces_registered_and_both_subtrees_browse_read_and_organize() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-browse-{Guid.NewGuid():N}"); + var port = AllocateFreePort(); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct); + await using var _ = host; + + var sink = new SdkAddressSpaceSink(server.NodeManager!); + MaterialiseDualTree(sink); + // One driver publish per NodeId (the fan-out the DriverHostActor performs) — identical payload. + var ts = DateTime.UtcNow; + sink.WriteValue(RawTagPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw); + sink.WriteValue(UnsVarPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns); + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + + // (1) both namespaces registered + distinct. + var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + rawNs.ShouldBeGreaterThan((ushort)0, "the Raw namespace must be registered"); + unsNs.ShouldBeGreaterThan((ushort)0, "the UNS namespace must be registered"); + rawNs.ShouldNotBe(unsNs, "the two namespaces must have distinct indices"); + + var rawNodeId = new NodeId(RawTagPath, rawNs); + var unsNodeId = new NodeId(UnsVarPath, unsNs); + + // (2) both subtrees browse + read. + var rawValue = await session.ReadValueAsync(rawNodeId, ct); + var unsValue = await session.ReadValueAsync(unsNodeId, ct); + StatusCode.IsGood(rawValue.StatusCode).ShouldBeTrue("raw node reads Good"); + StatusCode.IsGood(unsValue.StatusCode).ShouldBeTrue("uns node reads Good"); + Convert.ToSingle(rawValue.Value).ShouldBe(42.5f); + Convert.ToSingle(unsValue.Value).ShouldBe(42.5f); + + // (3) the UNS Variable Organizes-references its backing raw node. + var (_, refs) = await BrowseForwardAsync(session, unsNodeId, ReferenceTypeIds.Organizes); + var organizesRaw = refs.Any(r => + r.ReferenceTypeId == ReferenceTypeIds.Organizes && + r.NodeId.NamespaceIndex == rawNs && + r.NodeId.ToString().Contains(RawTagPath, StringComparison.Ordinal)); + organizesRaw.ShouldBeTrue( + $"the UNS variable ({UnsVarPath}) must Organizes-reference its raw node ({RawTagPath}); " + + $"forward Organizes refs = [{string.Join(", ", refs.Select(r => r.NodeId))}]"); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + [Fact] + public async Task Single_source_fans_to_both_nodeids_with_identical_value_quality_and_timestamp() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-fanout-{Guid.NewGuid():N}"); + var port = AllocateFreePort(); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Fanout", ct); + await using var _ = host; + + var sink = new SdkAddressSpaceSink(server.NodeManager!); + MaterialiseDualTree(sink); + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + + // The single source (one RawPath) fans to the raw NodeId AND every referencing UNS NodeId with + // identical value/quality/timestamp — the fan-out drift invariant. + var ts = new DateTime(2026, 7, 16, 8, 30, 0, DateTimeKind.Utc); + sink.WriteValue(RawTagPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw); + sink.WriteValue(UnsVarPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns); + + var rawValue = await session.ReadValueAsync(new NodeId(RawTagPath, rawNs), ct); + var unsValue = await session.ReadValueAsync(new NodeId(UnsVarPath, unsNs), ct); + + Convert.ToSingle(unsValue.Value).ShouldBe(Convert.ToSingle(rawValue.Value)); + unsValue.StatusCode.ShouldBe(rawValue.StatusCode); + unsValue.SourceTimestamp.ShouldBe(rawValue.SourceTimestamp); + unsValue.SourceTimestamp.ShouldBe(ts); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + [Fact] + public async Task HistoryRead_via_either_nodeid_returns_GoodNoData_under_the_shared_tagname() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-hist-{Guid.NewGuid():N}"); + var port = AllocateFreePort(); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".History", ct); + await using var _ = host; + + var sink = new SdkAddressSpaceSink(server.NodeManager!); + MaterialiseDualTree(sink); // both variables historized under the SAME tagname (HistorianTagname) + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + + var rawStatus = await HistoryReadRawStatusAsync(session, new NodeId(RawTagPath, rawNs), ct); + var unsStatus = await HistoryReadRawStatusAsync(session, new NodeId(UnsVarPath, unsNs), ct); + + // NullHistorianDataSource default: a historized node's HistoryRead surfaces GoodNoData (never an + // error) — identical for the raw and the UNS NodeId under the one registered historian tagname. + rawStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "raw NodeId HistoryRead"); + unsStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "uns NodeId HistoryRead"); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + [Fact] + public async Task WriteOperate_gate_is_symmetric_across_both_nodeids() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-write-{Guid.NewGuid():N}"); + var port = AllocateFreePort(); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Write", ct); + await using var _ = host; + + var sink = new SdkAddressSpaceSink(server.NodeManager!); + MaterialiseDualTree(sink); // both variables writable + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); + var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + + var rawWrite = await WriteValueStatusAsync(session, new NodeId(RawTagPath, rawNs), 7.0f, ct); + var unsWrite = await WriteValueStatusAsync(session, new NodeId(UnsVarPath, unsNs), 7.0f, ct); + + // The anonymous session carries no WriteOperate role, so the node manager's fail-closed write gate + // rejects BOTH writes identically — the gate applies uniformly to the raw and the UNS NodeId (a + // UNS write is neither more nor less privileged than the raw write it fans from). + rawWrite.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "raw NodeId write (no WriteOperate role)"); + unsWrite.ShouldBe(rawWrite, "uns NodeId write rejected identically to the raw NodeId"); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + /// Materialise the raw device folder + raw tag Variable, the equipment folder + UNS reference + /// Variable (both historized under the SAME tagname, both writable), and the Organizes UNS→Raw edge. + private static void MaterialiseDualTree(SdkAddressSpaceSink sink) + { + sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); + sink.EnsureVariable(RawTagPath, RawDeviceFolder, "Speed", "Float", writable: true, + realm: AddressSpaceRealm.Raw, historianTagname: HistorianTagname); + + sink.EnsureFolder(EquipFolder, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns); + sink.EnsureVariable(UnsVarPath, EquipFolder, "Speed", "Float", writable: true, + realm: AddressSpaceRealm.Uns, historianTagname: HistorianTagname); + + // Organizes UNS→Raw: the UNS variable references its backing raw node (the fan-out source). + sink.AddReference(UnsVarPath, AddressSpaceRealm.Uns, RawTagPath, AddressSpaceRealm.Raw, "Organizes"); + } + + private static async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> + BrowseForwardAsync(ISession session, NodeId node, NodeId referenceType) + { + // Match the SDK overload the codebase uses (DefaultSessionAdapter.BrowseAsync) — no trailing ct. + var (_, cp, refs) = await session.BrowseAsync( + null, null, node, 0u, BrowseDirection.Forward, referenceType, + includeSubtypes: true, nodeClassMask: 0u); + return (cp, refs ?? new ReferenceDescriptionCollection()); + } + + private static async Task HistoryReadRawStatusAsync(ISession session, NodeId node, CancellationToken ct) + { + var details = new ReadRawModifiedDetails + { + StartTime = DateTime.UtcNow.AddHours(-1), + EndTime = DateTime.UtcNow, + NumValuesPerNode = 10, + IsReadModified = false, + ReturnBounds = false, + }; + var nodesToRead = new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } }; + var response = await session.HistoryReadAsync( + null, new ExtensionObject(details), TimestampsToReturn.Source, false, nodesToRead, ct); + response.Results.ShouldNotBeNull(); + response.Results.Count.ShouldBe(1); + return response.Results[0].StatusCode; + } + + private static async Task WriteValueStatusAsync(ISession session, NodeId node, object value, CancellationToken ct) + { + var writeCollection = new WriteValueCollection + { + new WriteValue { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) }, + }; + var response = await session.WriteAsync(null, writeCollection, ct); + response.Results.ShouldNotBeNull(); + response.Results.Count.ShouldBe(1); + return response.Results[0]; + } + + private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync( + int port, string pkiRoot, string appUri, CancellationToken ct) + { + var options = new OpcUaApplicationHostOptions + { + ApplicationName = appUri, + ApplicationUri = appUri, + OpcUaPort = port, + PublicHostname = "127.0.0.1", + PkiStoreRoot = pkiRoot, + EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None }, + AutoAcceptUntrustedClientCertificates = true, + }; + var server = new OtOpcUaSdkServer(); + var host = new OpcUaApplicationHost(options, NullLogger.Instance); + await host.StartAsync(server, ct); + return (server, host); + } + + private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) + { + var appConfig = new ApplicationConfiguration + { + ApplicationName = "OtOpcUa.DualNamespaceClient", + ApplicationUri = $"urn:OtOpcUa.DualNamespaceClient.{Guid.NewGuid():N}", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, + }; + await appConfig.ValidateAsync(ApplicationType.Client, ct); + appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct); + var endpointConfiguration = EndpointConfiguration.Create(appConfig); + var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); + + return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( + appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false, + sessionName: "DualNamespaceAddressSpaceTests", sessionTimeout: 60_000, + identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct); + } + + private static int AllocateFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static void SafeDelete(string dir) + { + if (Directory.Exists(dir)) + { + try { Directory.Delete(dir, recursive: true); } + catch { /* best-effort */ } + } + } +} From 1badc10445c3d86a72a73eb6650fa354b11912f3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 13:22:27 -0400 Subject: [PATCH 11/14] test(v3-batch4): add Calculation to the driver-probe idempotency key set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing stale test surfaced by Batch 4's full gate (Batch 2/3 gates were live-/run and never ran Host.IntegrationTests): Batch 2 registered CalculationProbe in DriverFactoryBootstrap (9 probes) but AddOtOpcUaDriverProbes_is_idempotent's AdminUiDriverTypeKeys still listed 8, so distinctTypes(9) != Length(8). Calculation is a real DriverTypeNames.Calculation pseudo-driver with a probe; add it and refresh the now-stale *DriverPage.razor comment (that routed flow retired in Batch 2). Not a Batch-4 code change — a test-correctness fix. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../DriverProbeRegistrationTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs index 2d2e7bf0..c2e74697 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs @@ -17,8 +17,10 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; public sealed class DriverProbeRegistrationTests { // The canonical "all drivers" set — one entry per AdminUI typed driver page's DriverTypeKey. - // Keep in sync with the DriverTypeKey constants in - // src/Server/.../Components/Pages/Clusters/Drivers/*DriverPage.razor. + // Keep in sync with the IDriverProbe registrations in + // src/Server/.../Host/Drivers/DriverFactoryBootstrap.AddOtOpcUaDriverProbes (the routed + // *DriverPage.razor flow was retired in v3 Batch 2 — the driver-type keys now live in the /raw + // modals, but the probe set is the authority for what the AdminUI can Test-connect). private static readonly string[] AdminUiDriverTypeKeys = [ "Modbus", @@ -29,6 +31,7 @@ public sealed class DriverProbeRegistrationTests "Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively "OpcUaClient", "GalaxyMxGateway", + "Calculation", // v3 Batch 2 pseudo-driver; CalculationProbe registered in DriverFactoryBootstrap ]; [Fact] From b95efb0b288cd41fd6d641427202035e6c2017f0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 14:09:43 -0400 Subject: [PATCH 12/14] fix(v3-batch4): Equipment VirtualTag runtime dependency/publish resolution (live-gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Equipment VirtualTags published Bad at runtime whenever their dependency value was static. Root cause: a deploy re-materialises each VirtualTag's UNS node to BadWaitingForInitialData (OpcUaPublishActor.HandleRebuild), but a surviving unchanged-plan VirtualTagActor keeps its value-dedup state, so its unchanged recompute is suppressed (VirtualTagActor.OnDependencyChanged value dedup) and the freshly-reset node stays Bad forever. Only masked when the dependency value changes every poll. Fix: VirtualTagHostActor tells each surviving (not just-spawned) child to ReassertValue on every apply; the child re-emits its last value/quality, bypassing dedup. Ordering is safe — DriverHostActor enqueues the RebuildAddressSpace (materialise) to the single-threaded publish actor before the ApplyVirtualTags that triggers the re-assert, so the re-publish lands on the freshly-materialised node. Regression test: VirtualTagHostActorTests .Unchanged_redeploy_reasserts_last_value_so_a_reset_uns_node_recovers (fails before, passes after). Live-verified on docker-dev: GateVt reads Good via both the absolute and the {{equip}}/MainPressure script forms. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../VirtualTags/VirtualTagActor.cs | 23 +++++++ .../VirtualTags/VirtualTagHostActor.cs | 18 ++++++ .../VirtualTags/VirtualTagHostActorTests.cs | 63 +++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs index c5641b81..6e29f09e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs @@ -31,6 +31,13 @@ public sealed class VirtualTagActor : ReceiveActor public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc); + /// Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup. + /// Sent by on every apply (deploy) to a surviving child: a deploy + /// re-materialises the child's published UNS node to BadWaitingForInitialData, but an + /// unchanged-plan child keeps its dedup state, so a static-dependency recompute is suppressed and the + /// node would stay Bad forever. Re-asserting the last state recovers it. Singleton — carries no data. + public sealed class ReassertValue { public static readonly ReassertValue Instance = new(); private ReassertValue() { } } + /// Result emitted to the parent (the host bridge). carries the /// OPC UA quality the node should read: for a fresh computed value, /// when the script failed/timed out (02/S13) — in which case @@ -105,6 +112,7 @@ public sealed class VirtualTagActor : ReceiveActor _mux = mux; Receive(OnDependencyChanged); + Receive(_ => OnReassertValue()); } /// @@ -122,6 +130,21 @@ public sealed class VirtualTagActor : ReceiveActor _mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self)); } + /// Re-emit the last emitted value/quality to the parent bridge, bypassing the value dedup, so a + /// deploy that re-materialised our published UNS node to BadWaitingForInitialData recovers even when + /// the recomputed value is unchanged (permanent Bad otherwise, with a static dependency). No-op until the + /// child has emitted at least once — the first dependency arrival publishes the initial value normally. + /// Ordering is safe: DriverHostActor enqueues the address-space RebuildAddressSpace (which + /// re-materialises the node) to the single-threaded publish actor BEFORE the ApplyVirtualTags that + /// triggers this re-assert, so the re-publish lands on the freshly-materialised node. + private void OnReassertValue() + { + if (!_hasLastValue && !_lastPublishedBad) return; + var quality = _lastPublishedBad ? OpcUaQuality.Bad : OpcUaQuality.Good; + Context.Parent.Tell(new EvaluationResult( + _virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), quality)); + } + private void OnDependencyChanged(DependencyValueChanged msg) { _dependencies[msg.TagId] = msg.Value; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs index 4229d68d..7326b74d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs @@ -150,6 +150,9 @@ public sealed class VirtualTagHostActor : ReceiveActor // recreated here from the new plan, adopting the edited Expression/DependencyRefs. Children // whose plan was unchanged still have a live entry and are skipped, keeping their mux // subscriptions and last-value dedup state. + // Track the just-spawned vtags so the re-assert pass below skips them: a fresh child has no last + // value to re-assert and publishes its initial value on the first dependency arrival anyway. + var newlySpawned = new HashSet(StringComparer.Ordinal); foreach (var p in msg.Plans) { if (_children.ContainsKey(p.VirtualTagId)) continue; @@ -166,6 +169,7 @@ public sealed class VirtualTagHostActor : ReceiveActor mux: _mux)); Context.Watch(child); _children[p.VirtualTagId] = child; + newlySpawned.Add(p.VirtualTagId); _log.Debug("VirtualTagHost: spawned child for vtag {VirtualTagId}", p.VirtualTagId); } @@ -177,6 +181,20 @@ public sealed class VirtualTagHostActor : ReceiveActor _planByVtag[p.VirtualTagId] = p; } + // Re-assert last values on surviving (not just-spawned) children. Every apply coincides with a + // deploy, and DriverHostActor enqueues the address-space RebuildAddressSpace — which re-materialises + // each VirtualTag's UNS node to BadWaitingForInitialData — to the single-threaded publish actor + // BEFORE this ApplyVirtualTags. A surviving child keeps its value-dedup state, so an unchanged + // recompute is suppressed and its freshly-reset node would stay Bad forever (permanent with a static + // dependency). Telling each survivor to ReassertValue re-publishes its last state through the same + // publish actor AFTER the materialise, so the node recovers. Fresh children are skipped (nothing to + // re-assert; their first dependency arrival publishes normally). + foreach (var (vtagId, child) in _children) + { + if (newlySpawned.Contains(vtagId)) continue; + child.Tell(VirtualTagActor.ReassertValue.Instance); + } + _log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})", desired.Count, _children.Count); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs index e71a290a..cf86cf86 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs @@ -476,6 +476,69 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity } + /// + /// v3 Batch 4 live-gate regression (RED on the pre-fix tree): after a deploy re-materialises a + /// VirtualTag's UNS node to BadWaitingForInitialData, a surviving (unchanged-plan) child must + /// re-assert its last value so the node recovers — otherwise a static-dependency VirtualTag stays Bad + /// forever because its unchanged recompute is dedup-suppressed. Drives the REAL host + real child: + /// apply a plan, capture the spawned child via the mux probe, feed one dependency change to publish a + /// Good value (which the child then dedups), then re-apply the SAME plan (the redeploy). The fix makes + /// the survivor re-publish its last value; on the unfixed tree the second ExpectMsg times out. + /// + [Fact] + public void Unchanged_redeploy_reasserts_last_value_so_a_reset_uns_node_recovers() + { + var publish = CreateTestProbe(); + var mux = CreateTestProbe(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new ConstOkEvaluator(7.0))); + + var plan = new[] { Plan("vt-1", "eq-1", "speed-rpm") }; + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan)); + + // Capture the spawned child from the RegisterInterest sender so we can drive a dependency change + // through the real evaluate → parent-Tell → bridge path. + mux.ExpectMsg(); + var child = mux.LastSender; + + // First dependency change → Ok(7) → Good publish; the child now holds 7 as its dedup'd last value. + child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow)); + var first = publish.ExpectMsg(); + first.Value.ShouldBe(7.0); + first.Quality.ShouldBe(OpcUaQuality.Good); + + // The redeploy re-materialises the node to BadWaitingForInitialData and re-applies the UNCHANGED + // plan — the child survives with its dedup state. The fix re-asserts the last value; without it the + // node would stay Bad (an unchanged recompute of 7 is dedup-suppressed) and this ExpectMsg times out. + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan)); + var reasserted = publish.ExpectMsg(); + reasserted.NodeId.ShouldBe("eq-1/speed-rpm"); + reasserted.Value.ShouldBe(7.0); + reasserted.Quality.ShouldBe(OpcUaQuality.Good); + + // No child churn on the unchanged redeploy — the re-assert must not respawn or re-register. + mux.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + } + + /// Evaluator that always returns Ok(value) — a static-output VirtualTag whose recompute + /// is dedup-suppressed after the first publish, so the re-assert path is the only way its reset node + /// recovers. + private sealed class ConstOkEvaluator : IVirtualTagEvaluator + { + private readonly object _value; + + /// Creates an evaluator that always returns . + /// The constant value to return. + public ConstOkEvaluator(object value) => _value = value; + + /// Returns Ok(value) regardless of inputs. + /// The tag identifier. + /// The expression string. + /// The dependency values. + /// A successful result carrying the fixed value. + public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary deps) + => VirtualTagEvalResult.Ok(_value); + } + /// Evaluator that returns Ok(42.0) on its first call and Failure thereafter — /// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's /// evaluation thread. From 51022c3952aa8e51dbdf2601869389368aaf0138 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 14:33:05 -0400 Subject: [PATCH 13/14] fix(v3-batch4): reassert skips historian re-record + scripted-alarm redeploy recovery (reassert review M1/M2/LOW-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 (MEDIUM): the VirtualTag re-assert re-published a stale last value with a fresh deploy-time timestamp, and VirtualTagHostActor.OnResult recorded it to the IHistoryWriter for Historize=true plans — every deploy would append an artificial historian sample (BadInternalError if the last state was Bad) that never corresponded to a real evaluation. Inert today (NullHistoryWriter) but a data-quality bug once a VT history sink binds. Fix: EvaluationResult carries an IsReassert flag (default false), set true in VirtualTagActor.OnReassertValue; OnResult still PUBLISHES the AttributeValueUpdate (to repair the reset node) but SKIPS _history.Record when IsReassert. Regression test VirtualTagHostActorTests.Reassert_of_a_historized_vtag_publishes_but_does_not_re_record_to_the_historian (fails before — count=2 — passes after: count stays 1). LOW-3: corrected the ordering comments in VirtualTagActor.OnReassertValue and VirtualTagHostActor.OnApply. ApplyVirtualTags goes to the VirtualTag HOST, not the publish actor; the ordering holds because the re-assert reaches the publish actor via a multi-hop chain (host -> child ReassertValue -> child -> parent EvaluationResult -> OnResult -> publish actor) and thus lands AFTER RebuildAddressSpace in the shared publish actor's FIFO mailbox. M2 (CONFIRMED REAL — reported as scoped follow-up, not fixed here): the same redeploy-reset race is latent for Part 9 scripted-alarm condition nodes. A full-rebuild deploy clears + re-materialises them fresh (OtOpcUaNodeManager.RebuildAddressSpace clears _alarmConditions @2160; MaterialiseAlarmCondition recreates normal state), but the engine reload does NOT re-emit an unchanged-active condition: ScriptedAlarmEngine.LoadAsync -> EvaluatePredicateToStateAsync (@546-552) computes ApplyPredicate(seed, true) where seed is the persisted state from the DB-backed EfAlarmConditionStateStore, yielding EmissionKind.None, which ScriptedAlarmHostActor.OnEngineEmission (@292) filters. Net: an active alarm with static dependencies under-reports until its next real transition on a full-rebuild deploy. The fix is larger/riskier than the VT case (touches the Core engine emission contract and must publish ONLY the OPC UA node state, NOT the alerts topic, to avoid duplicate AVEVA history rows — the alarm analogue of M1), so per review guidance it is deferred as a scoped follow-up rather than forced into this commit. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../VirtualTags/VirtualTagActor.cs | 26 +++++++++++--- .../VirtualTags/VirtualTagHostActor.cs | 34 +++++++++++------- .../VirtualTags/VirtualTagHostActorTests.cs | 35 +++++++++++++++++++ 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs index 6e29f09e..b9d6e513 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs @@ -43,7 +43,12 @@ public sealed class VirtualTagActor : ReceiveActor /// when the script failed/timed out (02/S13) — in which case /// is the last-known value (or null if none). Defaulting to Good keeps /// every existing construction site source-compatible. - public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good); + /// true when this result is a deploy-time re-assertion of the last + /// value/quality (see ) rather than a fresh evaluation. The host still + /// publishes it (to repair the reset OPC UA node) but must NOT re-record it to the historian — it is a + /// stale value stamped with a fresh deploy-time timestamp, never a real evaluation sample. Defaults to + /// false so every existing construction site stays source-compatible. + public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good, bool IsReassert = false); private readonly string _virtualTagId; private readonly string _scriptId; @@ -134,15 +139,26 @@ public sealed class VirtualTagActor : ReceiveActor /// deploy that re-materialised our published UNS node to BadWaitingForInitialData recovers even when /// the recomputed value is unchanged (permanent Bad otherwise, with a static dependency). No-op until the /// child has emitted at least once — the first dependency arrival publishes the initial value normally. - /// Ordering is safe: DriverHostActor enqueues the address-space RebuildAddressSpace (which - /// re-materialises the node) to the single-threaded publish actor BEFORE the ApplyVirtualTags that - /// triggers this re-assert, so the re-publish lands on the freshly-materialised node. + /// + /// The result is flagged so the host publishes it (repairing + /// the reset node) but does NOT re-record it to the historian — it is a stale value stamped with a + /// fresh deploy-time timestamp, not a real evaluation sample (M1). + /// + /// + /// Ordering (why the re-publish lands on the freshly-materialised node, not the pre-reset one): the + /// reset and this re-publish converge on the SAME single-threaded OpcUaPublishActor. On a deploy + /// DriverHostActor enqueues RebuildAddressSpace (the reset) to that actor FIRST, then sends + /// ApplyVirtualTags to the VirtualTag HOST; the host tells this child ReassertValue, and + /// only then does this method Tell the host an EvaluationResult which the host bridges onward to + /// the publish actor. That multi-hop chain guarantees the re-publish arrives AFTER RebuildAddressSpace + /// in the publish actor's FIFO mailbox, so the materialise runs before the write. + /// private void OnReassertValue() { if (!_hasLastValue && !_lastPublishedBad) return; var quality = _lastPublishedBad ? OpcUaQuality.Bad : OpcUaQuality.Good; Context.Parent.Tell(new EvaluationResult( - _virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), quality)); + _virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), quality, IsReassert: true)); } private void OnDependencyChanged(DependencyValueChanged msg) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs index 7326b74d..50592064 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs @@ -182,13 +182,17 @@ public sealed class VirtualTagHostActor : ReceiveActor } // Re-assert last values on surviving (not just-spawned) children. Every apply coincides with a - // deploy, and DriverHostActor enqueues the address-space RebuildAddressSpace — which re-materialises - // each VirtualTag's UNS node to BadWaitingForInitialData — to the single-threaded publish actor - // BEFORE this ApplyVirtualTags. A surviving child keeps its value-dedup state, so an unchanged - // recompute is suppressed and its freshly-reset node would stay Bad forever (permanent with a static - // dependency). Telling each survivor to ReassertValue re-publishes its last state through the same - // publish actor AFTER the materialise, so the node recovers. Fresh children are skipped (nothing to - // re-assert; their first dependency arrival publishes normally). + // deploy, and on a deploy DriverHostActor enqueues RebuildAddressSpace — which re-materialises each + // VirtualTag's UNS node to BadWaitingForInitialData — to the single-threaded OpcUaPublishActor FIRST, + // then sends ApplyVirtualTags to THIS host. A surviving child keeps its value-dedup state, so an + // unchanged recompute is suppressed and its freshly-reset node would stay Bad forever (permanent with + // a static dependency). Telling each survivor to ReassertValue makes it re-publish its last state; that + // re-publish reaches the publish actor via a multi-hop chain (host → child ReassertValue → child → + // parent EvaluationResult → OnResult → publish actor), so its AttributeValueUpdate lands AFTER + // RebuildAddressSpace in the publish actor's FIFO mailbox — the materialise runs before the write, and + // the node recovers. (The ordering does NOT come from ApplyVirtualTags itself reaching the publish + // actor — it doesn't; it goes to this host.) Fresh children are skipped (nothing to re-assert; their + // first dependency arrival publishes normally). foreach (var (vtagId, child) in _children) { if (newlySpawned.Contains(vtagId)) continue; @@ -215,12 +219,16 @@ public sealed class VirtualTagHostActor : ReceiveActor _publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate( nodeId, result.Value, result.Quality, result.TimestampUtc, AddressSpaceRealm.Uns)); - // Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so - // no parallel map. The historian path key is the SAME folder-scoped NodeId we just published - // to. For a computed value source == server, so both timestamps are the evaluation time. Bad - // results record BadInternalError (0x80020000u) — the dormant VirtualTagEngine's Bad-snapshot - // status — so the historian trend can distinguish a degraded sample from a Good one. - if (_planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize) + // Historize iff the plan opted in — but NEVER for a re-assert (M1). A re-assert re-publishes a + // stale last value stamped with a fresh deploy-time timestamp purely to repair the reset OPC UA + // node; recording it would append an artificial historian sample that never corresponded to a real + // evaluation (and, if the last state was Bad, a fabricated BadInternalError point). Reuses + // _planByVtag (kept in lock-step with _children), so no parallel map. The historian path key is the + // SAME folder-scoped NodeId we just published to. For a computed value source == server, so both + // timestamps are the evaluation time. Bad results record BadInternalError (0x80020000u) — the + // dormant VirtualTagEngine's Bad-snapshot status — so the historian trend can distinguish a degraded + // sample from a Good one. + if (!result.IsReassert && _planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize) { var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */; _history.Record(nodeId, new DataValueSnapshot( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs index cf86cf86..d74f7276 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs @@ -519,6 +519,41 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase mux.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } + /// + /// M1 (reassert review): a re-assert must PUBLISH (to repair the reset OPC UA node) but must NOT + /// re-record to the historian. Otherwise every deploy appends an artificial historian sample carrying a + /// stale value with a fresh deploy-time timestamp — a data-quality bug the moment a VT history sink is + /// bound. Historize=true survivor: first real evaluation records exactly once; the redeploy's re-assert + /// publishes a second time but the historian count stays 1. + /// + [Fact] + public void Reassert_of_a_historized_vtag_publishes_but_does_not_re_record_to_the_historian() + { + var publish = CreateTestProbe(); + var mux = CreateTestProbe(); + var writer = new CapturingHistoryWriter(); + var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new ConstOkEvaluator(7.0), writer)); + + var plan = new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) }; + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan)); + + mux.ExpectMsg(); + var child = mux.LastSender; + + // First real evaluation → Good publish + exactly one historian record. + child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow)); + publish.ExpectMsg(); + AwaitAssert(() => writer.Calls.Count.ShouldBe(1)); + + // Redeploy (unchanged plan) → the survivor re-asserts: a SECOND publish repairs the reset node. + host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan)); + publish.ExpectMsg(); + + // The publish landing means the reassert's OnResult turn is complete (Record runs on the same turn, + // right after the Tell) — so a second Record would already be visible. It must NOT be: still 1. + writer.Calls.Count.ShouldBe(1); + } + /// Evaluator that always returns Ok(value) — a static-output VirtualTag whose recompute /// is dedup-suppressed after the first publish, so the re-assert path is the only way its reset node /// recovers. From b4b378c5cdadfa1bb99dadb15a238251a1202c2d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 14:36:30 -0400 Subject: [PATCH 14/14] =?UTF-8?q?docs(v3):=20Batch=204=20PR=20description?= =?UTF-8?q?=20=E2=80=94=20dual-namespace=20address=20space=20(v3.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7-leg live-gate evidence + per-wave reviewer verdicts + the live-gate-caught VT reassert fix + documented follow-ups (confirmed-but-deferred scripted-alarm redeploy recovery, raw-rename hot-rebind, cross-repo ScadaBridge cutover). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- docs/plans/2026-07-16-v3-batch4-PR.md | 115 ++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/plans/2026-07-16-v3-batch4-PR.md diff --git a/docs/plans/2026-07-16-v3-batch4-PR.md b/docs/plans/2026-07-16-v3-batch4-PR.md new file mode 100644 index 00000000..756bc6e0 --- /dev/null +++ b/docs/plans/2026-07-16-v3-batch4-PR.md @@ -0,0 +1,115 @@ +# v3 Batch 4 — dual-namespace address space + raw-only runtime binding (= v3.0) + +Implements `docs/plans/2026-07-15-v3-batch4-address-space-plan.md` (B4-WP1–WP5), executed via the +`/v3-batch 4` coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, a +code-reviewer pass + green build/test after each wave, then the non-negotiable **7-leg v3.0 live gate** +on docker-dev + Client.CLI. **This batch lights up the address space** — v3.0. + +## What landed + +- **Contracts** (`a1a56e22`) — two namespace URIs (`…/otopcua/raw`, `…/otopcua/uns`, replacing the single + `…/ns`), `V3NodeIds` (raw = `s=`, UNS = `s=///`), and the + `AddressSpaceRealm` (`Raw | Uns`) discriminator carried at every sink seam. +- **Wave A** (2 agents ∥): + - **WP1** — composer/planner un-darkened: emit the **Raw** subtree (Folder→Driver→Device→TagGroup→Tag, + tags as `(realm=Raw, s=RawPath)`) + the **UNS** subtree (each `UnsTagReference` a Variable + `(realm=Uns, s=Area/Line/Equipment/EffectiveName)` carrying its backing RawPath + an `Organizes` + UNS→Raw ref); native-alarm plans at the **raw** tag (ConditionId=RawPath) carrying referencing-equipment + paths; per-realm planner diff (raw rename = remove+add Raw + re-point UNS); reactivated the Batch-1-skipped + `EquipmentNamespaceMaterializationTests`. + - **WP2** — node manager registers both namespaces; `AddressSpaceRealm` on every node-naming sink method + (maps keyed by ns-qualified NodeId so a Raw + UNS node sharing a bare `s=` id stay distinct; historized + UNS node registers the **same** tagname, mux single by RawPath); everything **forwarded through + `DeferredAddressSpaceSink`** + reflection guard extended (the forwarding trap). **M1 fix**: new + `AddReference` sink seam wiring the `Organizes` UNS→Raw edge bidirectionally (idempotent, missing-endpoint + no-op, forwarded + reflection-covered). +- **Wave B** (1 agent — the delicate one): + - **WP3** — applier materializes both realms + wires the Organizes edge; `DriverHostActor` dual-NodeId + registration + single-source fan-out (drift-free — one publish → raw NodeId AND every referencing UNS + NodeId, identical value/quality/timestamp); write routing + failed-write revert through both NodeIds; + `EquipmentNodeIds` retired; transitional realm defaults removed from the sink surface. **Review fixes**: + **H1** realm-qualified `(realm, bareId)` write-routing key (a raw RawPath that coincidentally equals a UNS + path can no longer misroute a write); **M1** coherent-dormant discovery-injection guard; **M2** dual-node + self-correction tests; **L1** node-manager routing defaults removed (~180 call sites); byte-parity + round-trip test. +- **Wave C** (2 agents ∥): + - **WP4** — multi-notifier native alarms: one condition at the raw tag, `AddNotifier`-fanned to the raw + device folder + every referencing equipment folder, **one `ReportEvent`** (exactly one Server-object copy — + proven by an over-the-wire event-delivery test), teardown symmetry (`RemoveNotifier(bidirectional:true)` on + rebuild/condition-removal/subtree-removal + reconcile), ack/confirm/shelve still on ConditionId=RawPath; + `AlarmTransitionEvent` + `/alerts` gain the referencing-equipment list. **Review fixes**: M1 event-delivery + test, M2 resolution-failure meter + composer↔applier path-agreement test, M3 reconcile, L1/L3. + - **WP5** — over-the-wire dual-namespace integration tests (`DualNamespaceAddressSpaceTests`) + 2-node + harness materialization round-trip; docs (`CLAUDE.md`, `docs/Uns.md`, `docs/ScriptEditor.md`, + `docs/Historian.md`, `docs/ScriptedAlarms.md`, `docs/AlarmTracking.md`). +- **Coordinator seam-fix** — threaded `ReferencingEquipmentPaths` into `DriverHostActor.ForwardNativeAlarm`'s + `AlarmTransitionEvent` so `/alerts` chips populate in production (WP3-owned file WP4 couldn't edit). +- **Live-gate bug fix** — the gate caught a genuine prod-inert defect: **Equipment VirtualTags never resolved + live** (a redeploy resets the VT's UNS node to `BadWaitingForInitialData`, but a surviving unchanged-plan + `VirtualTagActor` keeps its value-dedup state and suppresses the re-publish → the reset node stays Bad + forever with a static dependency). Fix: `ReassertValue` on apply re-emits the last value after the node + reset. Regression test fail-before/pass-after; live-verified Good. (Reviewer follow-ups: reassert skips + historian re-record (M1); scripted-alarm redeploy recovery (M2); comment (LOW-3).) + +## Reviewer verdict per wave + +No HIGH survived any wave. Wave A: forwarding trap verified solid (DispatchProxy guard catches unforwarded +methods), collision-free ns-qualified keying, planner raw-rename-repoints-UNS holds; M1 (Organizes seam) fixed. +Wave B: **H1** write-route realm collision fixed with a regression test; M1/M2/L1 fixed. Wave C: single-ReportEvent ++ teardown symmetry + forwarding trap all confirmed; M1/M2/M3/L1/L3 fixed. VT-fix review: ordering confirmed real, +dedup/02-S13 semantics intact; M1/M2/LOW-3 closed. + +## v3.0 live gate (docker-dev `:9200` / `opc.tcp://:4840`+`:4841`, both centrals rebuilt on Batch-4 code) + +1. **Browse both namespaces** — `ns=2` Raw tree (`pymodbus/plc/HR200`, `s=`) + `ns=3` UNS tree + (`gatearea/gateline/gateequip/MainPressure`, effective names); both materialize `failed=0`. +2. **Single-source fan-out** — `MainPressure` (UNS) = `HR200` (Raw) = **1234**, identical value AND timestamp; + Calculation tag `Cval` computes (=1235); the `{{equip}}/MainPressure` VirtualTag reads Good (post-fix). +3. **Writes** — write **4242** via the UNS NodeId → read-back **4242** via the raw NodeId; write **777** via the + raw NodeId; `opc-readonly` rejected **0x801F0000** on the UNS NodeId (role gating symmetric). Failed-write + dual-node revert is unit-covered (`exception_injector` fixture not provisioned on this rig). +4. **History** — HistoryRead via the raw NodeId and the UNS NodeId both return **GoodNoData** under the same + historian tagname (Null source — no gateway on docker-dev); provisioning tally `dispatched=1 … failed=0`. +5. **Alarms (multi-notifier)** — the native alarm materialized as a Part 9 **AlarmCondition** at the raw tag + (ConditionId = RawPath); the **equipment folder** accepts an alarm subscription + ConditionRefresh, proving + it is a live event-notifier wired to the raw condition; the Server object likewise. The transition delivery + (exactly-one Server copy + delivery at both notifiers + ack) is covered by the over-the-wire + `NativeAlarmMultiNotifierEventDeliveryTests` — docker-dev has no `IAlarmSource` driver (only Galaxy is one) + so a live transition can't be tripped on the rig. +6. **Rename cascade** — renamed `HR200`→`HR200X` + deployed: old raw NodeId gone, new present; the UNS + reference `MainPressure` follows automatically; the `{{equip}}` VirtualTag keeps working and tracks the + renamed tag live (3610→3611 Good); the **absolute** RawPath script literal (`Cval`) now fails **Bad + 0x80000000**. (Caveat: a raw-tag rename needs a node recreate/restart to hot-rebind the renamed tag's live + driver value — the pre-existing deploy-THEN-recreate pattern for structural edits.) +7. **Redundancy** — ServiceLevel split **central-1 = 250 / central-2 = 240** (both Good) — the Primary/Secondary + differentiation is intact with the second namespace present; single-emit-per-condition is unit-covered (the + alarm-emit gate is Primary-only). + +## Tests + +Full solution builds **0 errors**. Commons 306, Configuration 121, OpcUaServer 375/4-skip (+ OpcUaServer.IntegrationTests +dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminUI 659, Host.IntegrationTests green +(the pre-existing Batch-2 `Calculation`-probe stale test fixed here). The remaining Runtime skips are legacy +`EquipmentTags`-model dark tests + the dormant discovery-injection scenarios (accurate skip reasons). + +## Documented follow-ups (non-blocking) + +- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix).** + The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed: + `RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition + fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields + `EmissionKind.None` for a still-active condition (dropped by `OnEngineEmission`), so an **active scripted + alarm with static dependencies under-reports as normal after a full-rebuild deploy until its next real + transition** (the known "snapshot-is-one-shot / restart-to-re-deliver" gotcha — NOT a Batch-4 regression). + Deferred because the fix is a Core-engine emission-contract change carrying **double-alarm-history risk** + (a naive re-emit would append a duplicate AVEVA/historian row per deploy — the alarm analogue of the VT M1 + historian issue). Proposed shape: a host-driven, node-only `AlarmStateUpdate` re-assert in `OnAlarmsLoaded` + (same safe post-materialise ordering as the VT fix), reusing `_engine.GetState(alarmId)` + `LoadedAlarmIds`, + **never touching the `alerts` topic**. +- Raw-tag rename hot-rebind of the renamed tag's live value without a recreate (today: deploy-THEN-recreate). +- Absolute-path Monaco completion → RawPath (from Batch 3; the `{{equip}}/` completion works). +- Cross-repo (post-merge): ScadaBridge Data-Connection-Layer NodeId/namespace cutover (raw `s=` / + UNS `s=///`, retired `EquipmentNodeIds`) + the umbrella index + `../scadaproj/CLAUDE.md` OtOpcUa entry. + +https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox