From 8be82e02c2ff93251f9053383997da264131680f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Apr 2026 16:57:20 -0400 Subject: [PATCH] =?UTF-8?q?Path-based=20NodeIds=20=E2=80=94=20decouple=20c?= =?UTF-8?q?lient=20contract=20from=20driver=20address?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-refactor design minted OPC UA NodeIds directly from the driver's FullReference (the native-address string). That had three long-term problems: 1. OPC UA Part 3 §5.2.2 requires NodeIds to be immutable across a node's lifetime. A rename of the underlying device address — Galaxy attribute, S7 tag, Modbus register alias — changed the NodeId and broke every client that had pinned the previous identifier. 2. Two drivers with coincidentally-matching native addresses (e.g. `temp` in Modbus and `temp` in S7 under different Equipment rows) collided on the NodeId identifier. 3. TagConfig was being placed verbatim on the wire; for drivers whose TagConfig is JSON (every driver shipped today, per the CK_Tag_TagConfig_IsJson check constraint), clients saw the raw JSON blob as the NodeId string. Refactor: * DriverNodeManager.Variable now mints a stable path-based NodeId `{driverId}/{folder-path}/{browseName}` and records the driver-side FullReference in a new _fullRefByNodeId map. OnReadValue / OnWriteValue / ResolveFullRef look the FullReference up via that map instead of casting NodeId.Identifier. The old cast path is preserved as a fallback so any test fixture that still registers variables with FullRef-shaped NodeIds keeps working. * EquipmentNodeWalker.AddTagVariable now extracts the cross-driver `FullName` field from Tag.TagConfig before handing the address to DriverAttributeInfo. Every shipped driver stores the wire reference in TagConfig[FullName]; falling back to the raw string covers any future driver that wants an opaque non-JSON address. ExtractFullName is exposed internal for unit coverage. * scripts/e2e/test-galaxy.ps1 defaults updated to the new path-based NodeIds. Verified live against p7-smoke-galaxy on the dev box: `ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Source` reads return Status=0x00000000 with a real Galaxy byte-array value. Test suite: 195/195 Core.Tests + 283/283 Server.Tests green. Five new ExtractFullName / FullName-passthrough tests added. Task #112 GA-3 — golden-path read verified end-to-end; remaining E2E script stages still blocked on pre-existing issues (ScriptedAlarm predicate NRE on empty upstream cache, PowerShell $changeLines.Count guard), tracked separately. Task #134 — complete. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/e2e/test-galaxy.ps1 | 20 ++++--- .../OpcUa/EquipmentNodeWalker.cs | 35 +++++++++++- .../OpcUa/DriverNodeManager.cs | 39 +++++++++++-- .../OpcUa/EquipmentNodeWalkerTests.cs | 56 ++++++++++++++++++- 4 files changed, 134 insertions(+), 16 deletions(-) diff --git a/scripts/e2e/test-galaxy.ps1 b/scripts/e2e/test-galaxy.ps1 index 3a93eb8..b158856 100644 --- a/scripts/e2e/test-galaxy.ps1 +++ b/scripts/e2e/test-galaxy.ps1 @@ -49,16 +49,18 @@ OtOpcUa server endpoint. Default opc.tcp://localhost:4840. .PARAMETER SourceNodeId - NodeId of the driver-sourced Galaxy tag (numeric, writable preferred). - Default matches the Phase 7 seed — `ns=2;s=p7-smoke-tag-source`. + NodeId of the driver-sourced Galaxy tag (numeric, writable preferred). NodeIds + are path-based per OPC UA Part 3 §5.2.2 — the default matches the Phase 7 seed + walking `p7-smoke-galaxy` (DriverInstanceId) → `lab-floor` → `galaxy-line` → + `reactor-1` → `Source` (Tag.Name). .PARAMETER VirtualNodeId - NodeId of the VirtualTag computed as Source × 2 (Phase 7 scripting). - Default matches the Phase 7 seed — `ns=2;s=p7-smoke-vt-derived`. + NodeId of the VirtualTag computed as Source × 2 (Phase 7 scripting). Same + path-based scheme, ending in the VirtualTag.Name (`Doubled`). .PARAMETER AlarmNodeId - NodeId of the scripted-alarm Condition (fires when Source > 50). - Default matches the Phase 7 seed — `ns=2;s=p7-smoke-al-overtemp`. + NodeId of the scripted-alarm Condition (fires when Source > 50). Same + path-based scheme, ending in ScriptedAlarm.Name (`OverTemp`). .PARAMETER AlarmTriggerValue Value written to -SourceNodeId to push it over the alarm threshold. @@ -90,9 +92,9 @@ param( [string]$OpcUaUrl = "opc.tcp://localhost:4840", - [string]$SourceNodeId = "ns=2;s=p7-smoke-tag-source", - [string]$VirtualNodeId = "ns=2;s=p7-smoke-vt-derived", - [string]$AlarmNodeId = "ns=2;s=p7-smoke-al-overtemp", + [string]$SourceNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Source", + [string]$VirtualNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Doubled", + [string]$AlarmNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/OverTemp", [string]$AlarmTriggerValue = "75", [int]$ChangeWaitSec = 10, [int]$AlarmWaitSec = 10, diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs index 143ae61..7ed2eea 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -157,7 +158,7 @@ public static class EquipmentNodeWalker private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag) { var attr = new DriverAttributeInfo( - FullName: tag.TagConfig, + FullName: ExtractFullName(tag.TagConfig), DriverDataType: ParseDriverDataType(tag.DataType), IsArray: false, ArrayDim: null, @@ -166,6 +167,38 @@ public static class EquipmentNodeWalker 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. + /// + internal static string ExtractFullName(string tagConfig) + { + if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig; + try + { + using var doc = JsonDocument.Parse(tagConfig); + if (doc.RootElement.ValueKind == JsonValueKind.Object + && doc.RootElement.TryGetProperty("FullName", out var fullName) + && fullName.ValueKind == JsonValueKind.String) + { + return fullName.GetString() ?? tagConfig; + } + } + catch (JsonException) { /* fall through */ } + return tagConfig; + } + /// /// Parse (stored as the enum /// name string, decision #138) into the enum value. Unknown names fall back to diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs index 513fa56..3b1874f 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs @@ -50,6 +50,15 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder private FolderState? _driverRoot; private readonly Dictionary _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase); + // NodeId-identifier (string) → driver FullReference. OPC UA Part 3 §5.2.2 requires NodeIds + // to be immutable across a node's lifetime, which precludes minting them from the driver's + // native address (a backend rename would change the NodeId and break every subscribed + // client). NodeIds are therefore path-based (`{driverId}/{folder-path}/{browseName}`) and + // this map recovers the driver-side FullReference for read/write/history dispatch. The + // fallback in lookups preserves the pre-refactor behaviour for any caller that still + // registered a variable via a FullRef-shaped NodeId. + private readonly Dictionary _fullRefByNodeId = new(StringComparer.Ordinal); + // PR 26: SecurityClassification per variable, populated during Variable() registration. // OnWriteValue looks up the classification here to gate the write by the session's roles. // Drivers never enforce authz themselves — the classification is discovery-time metadata @@ -170,12 +179,17 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder { lock (Lock) { + // Path-based NodeId per OPC UA Part 3 §5.2.2 (NodeIds MUST NOT change across the + // node's lifetime). Shape `{driverId}/{folder-path}/{browseName}` is stable across + // driver-side renames of the underlying FullReference + keeps the identifier + // self-describing against the browse tree. + var nodeKey = $"{_currentFolder.NodeId.Identifier}/{browseName}"; var v = new BaseDataVariableState(_currentFolder) { SymbolicName = browseName, ReferenceTypeId = ReferenceTypeIds.Organizes, TypeDefinitionId = VariableTypeIds.BaseDataVariableType, - NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex), + NodeId = new NodeId(nodeKey, NamespaceIndex), BrowseName = new QualifiedName(browseName, NamespaceIndex), DisplayName = new LocalizedText(displayName), DataType = MapDataType(attributeInfo.DriverDataType), @@ -197,6 +211,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder _securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass; _writeIdempotentByFullRef[attributeInfo.FullName] = attributeInfo.WriteIdempotent; _sourceByFullRef[attributeInfo.FullName] = attributeInfo.Source; + _fullRefByNodeId[nodeKey] = attributeInfo.FullName; v.OnReadValue = OnReadValue; v.OnWriteValue = OnWriteValue; @@ -228,7 +243,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder private ServiceResult OnReadValue(ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp) { - var fullRef = node.NodeId.Identifier as string ?? ""; + var fullRef = NodeIdToFullRef(node.NodeId); var source = _sourceByFullRef.TryGetValue(fullRef, out var s) ? s : NodeSourceKind.Driver; var readable = SelectReadable(source, _readable, _virtualReadable, _scriptedAlarmReadable); @@ -717,7 +732,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder private ServiceResult OnWriteValue(ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp) { - var fullRef = node.NodeId.Identifier as string; + var fullRef = NodeIdToFullRef(node.NodeId); if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown; // Per Phase 7 plan decision #6 — virtual tags + scripted alarms reject direct @@ -1102,7 +1117,23 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder } } - private string? ResolveFullRef(NodeHandle handle) => handle.NodeId?.Identifier as string; + private string? ResolveFullRef(NodeHandle handle) + { + if (handle.NodeId is null) return null; + return NodeIdToFullRef(handle.NodeId); + } + + /// + /// Recover the driver-side FullReference for a given OPC UA . Looks + /// the identifier up in ; when no entry exists (e.g. for + /// legacy test fixtures that still register variables with FullRef-shaped NodeIds) we + /// fall through to the raw identifier string so those code paths keep working. + /// + private string NodeIdToFullRef(NodeId nodeId) + { + if (nodeId?.Identifier is not string key) return string.Empty; + return _fullRefByNodeId.TryGetValue(key, out var fullRef) ? fullRef : key; + } // Both the results list AND the parallel errors list must be populated — MasterNodeManager // merges them and the merged StatusCode is what the client sees. Leaving errors[i] at its diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs index c656c85..8e9f089 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerTests.cs @@ -258,6 +258,57 @@ public sealed class EquipmentNodeWalkerTests v.AttributeInfo.ScriptedAlarmId.ShouldBeNull(); } + [Fact] + public void ExtractFullName_unwraps_json_object_with_FullName_field() + { + EquipmentNodeWalker.ExtractFullName( + "{\"FullName\":\"MESReceiver_001.MoveInBatchID\",\"DataType\":\"Int32\"}") + .ShouldBe("MESReceiver_001.MoveInBatchID"); + } + + [Fact] + public void ExtractFullName_handles_S7_style_extra_fields() + { + EquipmentNodeWalker.ExtractFullName( + "{\"FullName\":\"DB1_DBW0\",\"Address\":\"DB1.DBW0\",\"DataType\":\"Int16\"}") + .ShouldBe("DB1_DBW0"); + } + + [Fact] + public void ExtractFullName_returns_raw_when_not_json() + { + // Drivers that opt out of JSON TagConfig still work — fallback preserves the literal + // string so the driver's IReadable sees whatever the row author stored. + EquipmentNodeWalker.ExtractFullName("raw-tag-ref").ShouldBe("raw-tag-ref"); + } + + [Fact] + public void ExtractFullName_returns_raw_when_json_missing_FullName_field() + { + EquipmentNodeWalker.ExtractFullName("{\"Address\":\"DB1.DBW0\"}") + .ShouldBe("{\"Address\":\"DB1.DBW0\"}"); + } + + [Fact] + public void Driver_tag_FullName_passes_through_from_TagConfig_json() + { + // The walker hands the driver the unwrapped FullName string so IReadable.ReadAsync + // sees the plain address, not the raw TagConfig JSON. Verifies the dispatch contract + // the path-based NodeId refactor relies on. + var eq = Eq("eq-1", "line-1", "oven-3"); + var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1", + tagConfig: "{\"FullName\":\"plc-01/HR200\",\"DataType\":\"Int32\"}"); + var content = new EquipmentNamespaceContent( + [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], + [eq], [tag]); + + var rec = new RecordingBuilder("root"); + EquipmentNodeWalker.Walk(rec, content); + + var v = rec.Children[0].Children[0].Children[0].Variables.Single(); + v.AttributeInfo.FullName.ShouldBe("plc-01/HR200"); + } + // ----- builders for test seed rows ----- private static UnsArea Area(string id, string name) => new() @@ -282,7 +333,8 @@ public sealed class EquipmentNodeWalkerTests MachineCode = "MC-" + name, }; - private static Tag NewTag(string tagId, string name, string dataType, string address, string? equipmentId) => new() + private static Tag NewTag(string tagId, string name, string dataType, string address, + string? equipmentId, string? tagConfig = null) => new() { TagRowId = Guid.NewGuid(), GenerationId = 1, @@ -292,7 +344,7 @@ public sealed class EquipmentNodeWalkerTests Name = name, DataType = dataType, AccessLevel = ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite, - TagConfig = address, + TagConfig = tagConfig ?? address, }; // ----- recording IAddressSpaceBuilder -----