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 67d9ef55..ab7d208c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs @@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; /// no-op against , so the actor stays safe to /// receive messages from the moment it boots. /// -public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink +public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink { private volatile IOpcUaAddressSpaceSink _inner = NullOpcUaAddressSpaceSink.Instance; @@ -69,4 +69,17 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink /// Rebuilds the address space through the inner sink. public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); + + /// Forwards an in-place tag-attribute update (F10b) to the inner sink when it supports the + /// surgical capability. Returns false otherwise — before the real SdkAddressSpaceSink is + /// swapped in (inner is still the null sink), or any inner sink that isn't surgical — so the caller + /// (Phase7Applier) falls back to a full rebuild. Without this forward the surgical optimization is + /// inert on every driver-role host, because actors inject THIS wrapper, not the inner sink. + /// The node ID of the variable to update in place. + /// Whether the node should be read/write. + /// null ⇒ not historized; non-null ⇒ Historizing + historian binding. + /// True when the inner sink applied the update; false when it lacks the capability or the node is missing. + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname) + => _inner is ISurgicalAddressSpaceSink surgical + && surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname); } 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 d4df9737..2fdae40e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs @@ -83,6 +83,53 @@ public sealed class DeferredAddressSpaceSinkTests call.HistorianTagname.ShouldBe("MyTag.PV"); } + /// F10b regression: the deferred wrapper MUST forward the surgical capability to a + /// surgical inner sink — otherwise Phase7Applier (which injects THIS wrapper on every + /// driver-role host, not the inner SdkAddressSpaceSink) never sees the capability and the + /// in-place tag-attribute optimization is inert in production (it silently falls back to rebuild). + [Fact] + public void UpdateTagAttributes_forwards_to_a_surgical_inner_sink() + { + var deferred = new DeferredAddressSpaceSink(); + var inner = new SurgicalRecordingSink { Result = true }; + deferred.SetSink(inner); + + ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: "MyTag.PV") + .ShouldBeTrue(); + + var call = inner.SurgicalCalls.ShouldHaveSingleItem(); + call.NodeId.ShouldBe("v-1"); + call.Writable.ShouldBeTrue(); + call.Historian.ShouldBe("MyTag.PV"); + } + + /// The surgical forward returns the inner's own result (false ⇒ node missing) so the caller + /// falls back to a full rebuild. + [Fact] + public void UpdateTagAttributes_returns_inner_result_when_node_missing() + { + var deferred = new DeferredAddressSpaceSink(); + deferred.SetSink(new SurgicalRecordingSink { Result = false }); + + ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: false, historianTagname: null) + .ShouldBeFalse(); + } + + /// When the inner sink does NOT implement (e.g. the + /// null sink before the real one is swapped in, or any non-surgical sink) the wrapper returns false + /// so the caller rebuilds. + [Fact] + public void UpdateTagAttributes_returns_false_when_inner_is_not_surgical() + { + var deferred = new DeferredAddressSpaceSink(); // default inner = null sink (not surgical) + ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null) + .ShouldBeFalse(); + + deferred.SetSink(new RecordingSink()); // a non-surgical inner + ((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null) + .ShouldBeFalse(); + } + /// Builds a minimal for the forwarding tests (the /// inner sink only records the node id, so the exact state values don't matter here). private static AlarmConditionSnapshot Snapshot(bool active = false) => @@ -123,4 +170,32 @@ public sealed class DeferredAddressSpaceSinkTests /// public void RebuildAddressSpace() => CallQueue.Enqueue("RB"); } + + private sealed class SurgicalRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink + { + /// Gets or sets the value returns. + public bool Result { get; set; } = true; + /// Gets the recorded surgical calls. + public List<(string NodeId, bool Writable, string? Historian)> SurgicalCalls { get; } = new(); + + /// + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname) + { + SurgicalCalls.Add((variableNodeId, writable, historianTagname)); + return Result; + } + + /// + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + /// + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } + /// + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } + /// + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + /// + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } + /// + public void RebuildAddressSpace() { } + } }