From a1a56e22bb6967e3bf5b2af51ae89dafa8e13f8c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 09:03:29 -0400 Subject: [PATCH] =?UTF-8?q?feat(v3-batch4):=20contracts=20=E2=80=94=20dual?= =?UTF-8?q?-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); + } +}