feat(opcua): FB-7 surgical DataType/array-shape in-place tag writes
Widen the F10b surgical address-space path so a changed equipment tag whose only differences are DataType / IsArray / ArrayLength (on top of the existing Writable / Historizing) is applied IN PLACE on the live node instead of forcing a full RebuildAddressSpace that drops every client's subscriptions server-wide. ISurgicalAddressSpaceSink.UpdateTagAttributes gains (dataType, isArray, arrayLength); the DeferredAddressSpaceSink wrapper forwards all six args (the prod-inertness seam). OtOpcUaNodeManager swaps DataType + ValueRank + ArrayDimensions in place, and on a real shape change (a) resets the node to BadWaitingForInitialData so no stale wrong-typed value is exposed (closes the prior brief-value-type-mismatch objection) and (b) raises a Part 3 GeneralModelChangeEvent (verb=DataTypeChanged) so model-aware clients re-read the definition. A Writable/Historizing-only change leaves the shape untouched (no reset, no model event) — original behaviour preserved byte-for-byte. AddressSpaceApplier.TagDeltaIsSurgicalEligible adds the three shape fields to its whitelist; FullName/Name/DriverInstanceId/alarm differences still rebuild. Tests: new NodeManagerSurgicalShapeUpdateTests boots a real server to prove the in-place swap + value reset + the no-reset backward-compat path + the model-event builder; AddressSpaceApplierTests invert the two former DataType/IsArray-rebuild cases to surgical and assert the shape args land; DeferredAddressSpaceSinkTests assert the shape args forward. 273/273 OpcUaServer.Tests green; full solution builds.
This commit is contained in:
@@ -94,13 +94,19 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
var inner = new SurgicalRecordingSink { Result = true };
|
||||
deferred.SetSink(inner);
|
||||
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: "MyTag.PV")
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: "MyTag.PV",
|
||||
dataType: "Int32", isArray: true, arrayLength: 8u)
|
||||
.ShouldBeTrue();
|
||||
|
||||
var call = inner.SurgicalCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe("v-1");
|
||||
call.Writable.ShouldBeTrue();
|
||||
call.Historian.ShouldBe("MyTag.PV");
|
||||
// FB-7: the DataType/array-shape args must forward verbatim too — a partial forward would silently
|
||||
// drop the shape update on every driver-role host.
|
||||
call.DataType.ShouldBe("Int32");
|
||||
call.IsArray.ShouldBeTrue();
|
||||
call.ArrayLength.ShouldBe(8u);
|
||||
}
|
||||
|
||||
/// <summary>The surgical forward returns the inner's own result (false ⇒ node missing) so the caller
|
||||
@@ -111,7 +117,8 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
var deferred = new DeferredAddressSpaceSink();
|
||||
deferred.SetSink(new SurgicalRecordingSink { Result = false });
|
||||
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: false, historianTagname: null)
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: false, historianTagname: null,
|
||||
dataType: "Float", isArray: false, arrayLength: null)
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -122,11 +129,13 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
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)
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null,
|
||||
dataType: "Float", isArray: false, arrayLength: null)
|
||||
.ShouldBeFalse();
|
||||
|
||||
deferred.SetSink(new RecordingSink()); // a non-surgical inner
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null)
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null,
|
||||
dataType: "Float", isArray: false, arrayLength: null)
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -175,13 +184,13 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
{
|
||||
/// <summary>Gets or sets the value <see cref="UpdateTagAttributes"/> returns.</summary>
|
||||
public bool Result { get; set; } = true;
|
||||
/// <summary>Gets the recorded surgical calls.</summary>
|
||||
public List<(string NodeId, bool Writable, string? Historian)> SurgicalCalls { get; } = new();
|
||||
/// <summary>Gets the recorded surgical calls (incl. the FB-7 DataType/array-shape args).</summary>
|
||||
public List<(string NodeId, bool Writable, string? Historian, string DataType, bool IsArray, uint? ArrayLength)> SurgicalCalls { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname)
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
|
||||
{
|
||||
SurgicalCalls.Add((variableNodeId, writable, historianTagname));
|
||||
SurgicalCalls.Add((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength));
|
||||
return Result;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user