From 3fc258bd42aad6e96a2597975a38edd82f489ce8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 13:32:53 -0400 Subject: [PATCH] feat(opcua): add ISurgicalAddressSpaceSink + node-manager in-place tag-attribute update (F10b) --- .../OpcUa/ISurgicalAddressSpaceSink.cs | 13 ++++++ .../OtOpcUaNodeManager.cs | 40 +++++++++++++++++-- .../SdkAddressSpaceSink.cs | 10 ++++- 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs new file mode 100644 index 00000000..98e11aa5 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +/// Optional capability on an address-space sink: surgical in-place attribute updates on an +/// EXISTING variable node, used by Phase7Applier to avoid a full RebuildAddressSpace for pure-property +/// tag changes (Writable / Historizing). A sink that does not implement it ⇒ caller falls back to a +/// full rebuild (safe default). +public interface ISurgicalAddressSpaceSink +{ + /// Update an existing variable node's Writable (AccessLevel + inbound-write handler) and + /// Historizing (+ historian-tagname binding) IN PLACE, notifying subscribers (ClearChangeMasks) + /// without a rebuild. Returns false if the node does not exist (caller should rebuild instead). + bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 92669fc3..ee503ef5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -1332,9 +1332,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // access bit (on top of the writable composite) so clients can browse + HistoryRead it. var historized = historianTagname is not null; // The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is - // CurrentRead | CurrentWrite. OR-ing two byte constants promotes to int, so cast back. - byte access = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead; - if (historized) access = (byte)(access | AccessLevels.HistoryRead); + // CurrentRead | CurrentWrite, with the HistoryRead bit OR-ed in when historized. + var access = ComposeAccessLevel(writable, historized); var variable = new BaseDataVariableState(parent) { NodeId = new NodeId(variableNodeId, NamespaceIndex), @@ -1369,6 +1368,41 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } + /// Compose the AccessLevel/UserAccessLevel byte for a tag variable from its Writable + + /// Historizing flags — the single source of truth shared by (node + /// creation) and (F10b surgical in-place update). The SDK exposes + /// the flags separately (no CurrentReadWrite composite): ReadWrite is CurrentRead | CurrentWrite, + /// with the HistoryRead bit OR-ed in when historized. OR-ing byte constants promotes to int, so cast back. + private static byte ComposeAccessLevel(bool writable, bool historized) + { + byte access = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead; + if (historized) access = (byte)(access | AccessLevels.HistoryRead); + return access; + } + + /// F10b surgical counterpart of : update an EXISTING tag variable's + /// Writable (AccessLevel + handler) and Historizing (+ historian-tagname + /// binding) in place and notify subscribers, WITHOUT a rebuild — so client MonitoredItems on the node + /// survive. Returns false when the node id is unknown (caller falls back to a full rebuild). + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname) + { + ArgumentException.ThrowIfNullOrEmpty(variableNodeId); + lock (Lock) + { + if (!_variables.TryGetValue(variableNodeId, out var v)) return false; + var historized = historianTagname is not null; + var access = ComposeAccessLevel(writable, historized); + v.AccessLevel = access; + v.UserAccessLevel = access; + v.Historizing = historized; + v.OnWriteValue = writable ? OnEquipmentTagWrite : null; + if (historized) _historizedTagnames[variableNodeId] = historianTagname!; + else _historizedTagnames.TryRemove(variableNodeId, out _); + v.ClearChangeMasks(SystemContext, includeChildren: false); + return true; + } + } + /// Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String", /// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType /// (matches CreateVariable's default for lazy-created nodes). diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index 9894e33b..709b1828 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// . The host wires this in once the StandardServer has /// been started (so the node manager exists). /// -public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink +public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink { private readonly OtOpcUaNodeManager _nodeManager; @@ -65,6 +65,14 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); + /// F10b: surgically update an existing variable node's Writable + Historizing in place + /// (no rebuild). Returns false when the node does not exist (caller falls back to a full rebuild). + /// The variable node identifier. + /// When true the node becomes read/write with the inbound-write handler; otherwise read-only. + /// null ⇒ not historized; non-null ⇒ Historizing with the HistoryRead bit and tagname binding. + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname) + => _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname); + /// Rebuilds the entire OPC UA address space. public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace(); }