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.