From 906800abc0dfa56fc30a018cbc64665cb40083cb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 19:26:55 -0400 Subject: [PATCH] v3 batch1 Wave-B contracts: byName-only resolver + RawTagEntry + green Core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EquipmentTagRefResolver re-keyed to RawPath: byName(RawPath)-only lookup, blob-parse fallback deleted (a miss is a miss). Clear() retained as documented no-op. - New RawTagEntry(RawPath, TagConfig, WriteIdempotent) in Core.Abstractions — the artifact->driver tag-delivery unit both WP5 (factory consumer) and WP4 (artifact producer) code against. - EquipmentNodeWalker greened for the dark Batch-1 address space: drop the raw-tag emission path (relied on removed Tag.EquipmentId/FullName); keep Area/Line/Equipment folders + VirtualTags + ScriptedAlarms. Raw-tag reference materialization is Batch 4. Commons, Core.Abstractions, Configuration, Core all build green. --- .../EquipmentTagRefResolver.cs | 67 ++++--- .../OpcUa/EquipmentNodeWalker.cs | 174 +++--------------- 2 files changed, 66 insertions(+), 175 deletions(-) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs index d95bafb7..d34284d7 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs @@ -1,53 +1,60 @@ -using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// -/// Resolves a driver subscription/read/write fullReference to a driver tag-definition, -/// bridging the two authoring models: a legacy authored tag-table entry (looked up by name) -/// OR an equipment tag whose reference is its raw TagConfig JSON (parsed on first use -/// and cached). Negative results are cached too, so a genuinely-unknown reference is parsed once. +/// v3: resolves a driver subscription/read/write reference — which is now always a +/// RawPath — to the driver's internal tag-definition. The lookup is a single authored-table +/// hit: each deploy hands the driver its authored raw tags keyed by RawPath (see +/// ), the driver builds a RawPath → TDef table from them, and this +/// resolver wraps that table's lookup. The pre-v3 blob-parse fallback (an equipment tag whose +/// reference WAS its raw TagConfig JSON) is retired: a miss is a miss — +/// returns and the driver surfaces Bad quality, never a parse attempt. /// /// The driver's internal tag-definition type. public sealed class EquipmentTagRefResolver where TDef : class { - private readonly Func _byName; - private readonly Func _parseRef; - private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + private readonly Func _byRawPath; /// Initializes a new instance of the class. - /// Authored tag-table lookup (returns null on miss). - /// Parses an equipment-tag reference (TagConfig JSON) into a transient def, or null. - public EquipmentTagRefResolver(Func byName, Func parseRef) + /// The driver's authored-table lookup (RawPath → definition; null on miss). + public EquipmentTagRefResolver(Func byRawPath) { - ArgumentNullException.ThrowIfNull(byName); - ArgumentNullException.ThrowIfNull(parseRef); - _byName = byName; - _parseRef = parseRef; + ArgumentNullException.ThrowIfNull(byRawPath); + _byRawPath = byRawPath; } - /// True when resolves to a def (authored or equipment). - /// The wire reference handed to the driver. + /// True when resolves to an authored tag-definition. + /// The RawPath wire reference handed to the driver. /// /// The resolved tag-definition when this returns . When this returns /// the value is undefined — callers must not use it. - /// [MaybeNullWhen(false)] informs the nullable-reference-types (NRT) analyser so - /// callers in NRT-enabled contexts do not need to suppress warnings. /// /// when a definition was found. - public bool TryResolve(string fullReference, [MaybeNullWhen(false)] out TDef def) + public bool TryResolve(string rawPath, [MaybeNullWhen(false)] out TDef def) { - var authored = _byName(fullReference); - if (authored is not null) { def = authored; return true; } - - var resolved = _cache.GetOrAdd(fullReference, _parseRef); - if (resolved is not null) { def = resolved; return true; } - - def = default; - return false; + def = _byRawPath(rawPath); + return def is not null; } - /// Drops the transient-parse cache (call on driver reinitialise so a config change re-parses). - public void Clear() => _cache.Clear(); + /// + /// No-op retained for call-site compatibility. v3 holds no transient parse cache — the driver + /// rebuilds its authored RawPath → TDef table on reinitialise, and this resolver reads + /// the driver's live table by closure, so there is nothing to clear here. + /// + public void Clear() + { + } } + +/// +/// The unit of tag delivery from the deploy artifact to a driver: one authored raw +/// Tag, identified by its (the v3 identity), carrying the +/// driver-specific address blob () the driver dials and the platform flags +/// the driver needs. A driver maps each entry to its typed definition (keyed by RawPath) via its +/// own TagConfig → definition factory at table-build time. +/// +/// The tag's cluster-scoped RawPath — the driver wire reference / identity. +/// The tag's schemaless driver-specific TagConfig JSON (the dialled address). +/// Whether writes to this tag are safe to replay (R2 resilience contract). +public sealed record RawTagEntry(string RawPath, string TagConfig, bool WriteIdempotent); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs index 96eacf2b..5360df87 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs @@ -1,80 +1,37 @@ -using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa; /// -/// Materializes the canonical Unified Namespace browse tree for an Equipment-kind -/// from the Config DB's -/// UnsArea / UnsLine / Equipment / Tag rows. Runs during -/// address-space build per whose -/// Namespace.Kind = Equipment; non-Equipment namespaces are -/// exempt and reach this walker only indirectly through -/// . +/// Materializes the canonical Unified Namespace browse tree (Area → Line → Equipment) for a +/// cluster from its UnsArea / UnsLine / Equipment rows, plus the +/// per-equipment VirtualTag + ScriptedAlarm nodes. +/// +/// v3: equipment no longer binds a driver or authors tags. Raw tags are surfaced into +/// equipment by UnsTagReference and materialized as the UNS-namespace fan-out of their +/// backing raw nodes — that projection lands with the dual-namespace address space (Batch 4), +/// NOT here. This walker therefore emits the equipment hierarchy + VirtualTags + ScriptedAlarms +/// only; raw-tag reference variables are intentionally absent (the address space is deliberately +/// dark until Batch 4). It remains a pure, SDK-free helper retained for unit-test support; real +/// server deployments compose through the AddressSpaceComposer → Applier → Sink → NodeManager +/// chain. +/// /// -/// -/// -/// Composition strategy. Accepted Option A — Config -/// primary. The walker treats the supplied -/// snapshot as the authoritative published surface. Every Equipment row becomes a -/// folder node at the UNS level-5 segment; every bound to an -/// Equipment (non-null ) becomes a variable node under -/// it. Driver-discovered tags that have no Config-DB row are not added by this -/// walker — the ITagDiscovery path continues to exist for FolderPath-scoped tags + -/// for enrichment, but Equipment-kind composition is fully Tag-row-driven. -/// -/// -/// -/// Under each Equipment node. Five identifier properties -/// (EquipmentId, EquipmentUuid, MachineCode, ZTag, -/// SAPID) are added as OPC UA properties — external systems (ERP, SAP PM) -/// resolve equipment by whichever identifier they natively use without a sidecar. -/// materializes the OPC 40010 -/// Identification sub-folder with the nine fields when at least one -/// is non-null; when all nine are null the sub-folder is omitted rather than -/// appearing empty. -/// -/// -/// -/// Address resolution. Variable nodes carry the driver-side full reference -/// in copied from Tag.TagConfig -/// (the wire-level address JSON blob whose interpretation is driver-specific). At -/// runtime the dispatch layer routes Read/Write calls through the configured -/// capability invoker; an unreachable address surfaces as an OPC UA Bad status via -/// the natural driver-read failure path, NOT as a build-time reject. The ADR calls -/// this "BadNotFound placeholder" behavior — legible to operators via their Admin -/// UI + OPC UA client inspection of node status. -/// -/// -/// -/// Pure function. This class has no dependency on the OPC UA SDK, no -/// Config-DB access, no state. It consumes pre-loaded EF Core rows + streams calls -/// into the supplied . The server-side wiring -/// (load snapshot → invoke walker → per-tag capability probe) lives in the Task B -/// PR alongside NodeScopeResolver's Config-DB join. -/// -/// public static class EquipmentNodeWalker { /// - /// Walk into . - /// The builder is scoped to the Equipment-kind namespace root; the walker emits - /// Area → Line → Equipment folders under it, then identifier properties + the - /// Identification sub-folder + variable nodes per bound Tag under each Equipment. + /// Walk into : Area → Line → + /// Equipment folders, identifier properties + the OPC 40010 Identification sub-folder per + /// equipment, and the equipment's VirtualTag + ScriptedAlarm variable nodes. /// - /// - /// The builder scoped to the Equipment-kind namespace root. Caller is responsible for - /// creating this (e.g. rootBuilder.Folder(namespace.NamespaceId, namespace.NamespaceUri)). - /// - /// Pre-loaded + pre-filtered rows for a single published generation. + /// The builder scoped to the UNS namespace root. + /// Pre-loaded + pre-filtered rows for a single cluster. public static void Walk(IAddressSpaceBuilder namespaceBuilder, EquipmentNamespaceContent content) { ArgumentNullException.ThrowIfNull(namespaceBuilder); ArgumentNullException.ThrowIfNull(content); - // Group lines by area + equipment by line + tags by equipment up-front. Avoids an - // O(N·M) re-scan at each UNS level on large fleets. var linesByArea = content.Lines .GroupBy(l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.OrderBy(l => l.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); @@ -83,11 +40,6 @@ public static class EquipmentNodeWalker .GroupBy(e => e.UnsLineId, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); - var tagsByEquipment = content.Tags - .Where(t => !string.IsNullOrEmpty(t.EquipmentId)) - .GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase); - var virtualTagsByEquipment = (content.VirtualTags ?? []) .Where(v => v.Enabled) .GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase) @@ -114,10 +66,6 @@ public static class EquipmentNodeWalker AddIdentifierProperties(equipmentBuilder, equipment); IdentificationFolderBuilder.Build(equipmentBuilder, equipment); - if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) - foreach (var tag in equipmentTags) - AddTagVariable(equipmentBuilder, tag); - if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags)) foreach (var vtag in vTags) AddVirtualTagVariable(equipmentBuilder, vtag); @@ -131,11 +79,9 @@ public static class EquipmentNodeWalker } /// - /// Adds the five operator-facing identifiers as OPC UA properties - /// on the Equipment node. EquipmentId + EquipmentUuid are always populated; - /// MachineCode is required per ; ZTag + SAPID are nullable in - /// the data model so they're skipped when null to avoid empty-string noise in the - /// browse tree. + /// Adds the operator-facing identifiers as OPC UA properties on the Equipment node. + /// EquipmentId + EquipmentUuid + MachineCode are always populated; ZTag + SAPID are skipped + /// when null to avoid empty-string noise in the browse tree. /// private static void AddIdentifierProperties(IAddressSpaceBuilder equipmentBuilder, Equipment equipment) { @@ -149,67 +95,13 @@ public static class EquipmentNodeWalker } /// - /// Emit a single Tag row as an . The driver - /// full reference lives in Tag.TagConfig (wire-level address, driver-specific - /// JSON blob); the variable node's data type derives from Tag.DataType. - /// Unreachable-address behavior: the variable is created; the - /// driver's natural Read failure surfaces an OPC UA Bad status at runtime. - /// - private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag) - { - // SecurityClass and IsHistorized are intentionally not extracted from TagConfig here. - // In production, EquipmentNodeWalker.Walk is superseded by the - // AddressSpaceComposer → AddressSpaceApplier → Sink → NodeManager chain, which reads - // both fields from TagConfig JSON directly (via DeploymentArtifact.ExtractTagHistorize - // and the SecurityClassification column). This walker is retained for unit-test support - // only; real server deployments never invoke Walk to build live nodes. - var attr = new DriverAttributeInfo( - FullName: ExtractFullName(tag.TagConfig), - DriverDataType: ParseDriverDataType(tag.DataType), - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.FreeAccess, - IsHistorized: false); - equipmentBuilder.Variable(tag.Name, tag.Name, attr); - } - - /// - /// Cross-driver TagConfig convention — the Config DB's CK_Tag_TagConfig_IsJson - /// check constraint requires TagConfig to be a JSON object, and every shipped driver - /// (Galaxy / Modbus / AB CIP / S7 / FOCAS / TwinCAT / ABLegacy) stores the wire-level - /// address in a top-level FullName field. Extracting it here keeps the walker - /// driver-agnostic while giving the driver the plain address string its backend - /// expects at read-time — the raw JSON would otherwise be passed verbatim to - /// IReadable.ReadAsync and the driver would fail to resolve the tag. - /// - /// - /// Falls back to the raw if it doesn't parse as JSON or - /// the FullName field is absent. This preserves the pre-refactor behaviour for - /// any legacy row that slipped past the check constraint or any future driver that - /// wants an opaque non-JSON reference. - /// - /// The tag configuration JSON or string. - /// The extracted FullName value, or the raw if it can't be extracted. - internal static string ExtractFullName(string tagConfig) => - TagConfigIntent.Parse(tagConfig).FullName; - - /// - /// Parse (stored as the enum - /// name string) into the enum value. Unknown names fall back to - /// so a one-off driver-specific type doesn't - /// abort the whole walk; the underlying driver still sees the original TagConfig - /// address + can surface its own typed value via the OPC UA variant at read time. + /// Parse a data-type name (stored as the enum name) into the + /// enum value; unknown names fall back to . /// private static DriverDataType ParseDriverDataType(string raw) => Enum.TryParse(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String; - /// - /// Emit a row as a - /// variable node. FullName doubles as the UNS path Phase 7's VirtualTagEngine - /// addresses its engine-side entries by. The VirtualTagId discriminator lets - /// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any - /// driver. - /// + /// Emit a row as a variable node. private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag) { var attr = new DriverAttributeInfo( @@ -227,14 +119,7 @@ public static class EquipmentNodeWalker equipmentBuilder.Variable(vtag.Name, vtag.Name, attr); } - /// - /// Emit a row as a - /// variable node. The OPC UA Part 9 alarm-condition materialization happens at the - /// node-manager level (which wires the concrete AlarmConditionState subclass - /// per ); this walker provides the browse-level - /// anchor + the flag that triggers that - /// materialization path. - /// + /// Emit a row as a variable node. private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm) { var attr = new DriverAttributeInfo( @@ -254,15 +139,14 @@ public static class EquipmentNodeWalker } /// -/// Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config -/// DB rows. All four collections are scoped to the same -/// row. The walker assumes this filter -/// was applied by the caller + does no cross-namespace validation. +/// Pre-loaded + pre-filtered snapshot of one cluster's UNS content (Area / Line / Equipment + +/// per-equipment VirtualTags / ScriptedAlarms). v3: raw-tag references (UnsTagReference) +/// are materialized by the Batch-4 dual-namespace fan-out, not by this walker, so they are not +/// part of this snapshot. /// public sealed record EquipmentNamespaceContent( IReadOnlyList Areas, IReadOnlyList Lines, IReadOnlyList Equipment, - IReadOnlyList Tags, IReadOnlyList? VirtualTags = null, IReadOnlyList? ScriptedAlarms = null);