fix(address-space): numeric array writes convert (checked) to the node element type; array-to-scalar refusal only for Modbus/S7 (#610 review R6)
MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list writes (its element types are only Boolean/Int32/Float/Double/String/ DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a Double list to a Float array. EvaluateEquipmentWriteStructure now converts a numeric array of another numeric element type element-wise, CHECKED, into the node's element type and hands the converted array back through OnEquipmentTagWrite's ref value, so the node stores and the driver receives the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to- integer or Float-overflowing element refuses the whole write BadTypeMismatch (nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays closed. Non-numeric mismatches stay refused. The wrong 'no client relies on a looser rule' comment is corrected. MINOR-1: the array-value-to-scalar-node refusal broke writes back to browse-committed array tags (committed scalar, isArray never set) on pass-through drivers. It now applies only when the owning driver's scalar encoder cannot take an array (DriverArrayWriteSupport. RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan. ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue -> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the classifier whitelist (driver-type edit rebuilds); a mirror whose flag changes is recreated, not updated in place. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -34,6 +34,7 @@ public sealed class ArrayWriteWireTests
|
||||
private const string S7ArrayUns = "filling/line-1/station-1/Levels";
|
||||
private const string S7ScalarRaw = "Plant/S7a/plc1/Setpoint";
|
||||
private const string GalaxyArrayRaw = "Plant/Gx1/gx/Recipe";
|
||||
private const string UaScalarRaw = "Plant/Ua1/up/Vector";
|
||||
|
||||
private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
|
||||
{
|
||||
@@ -73,6 +74,20 @@ public sealed class ArrayWriteWireTests
|
||||
Name = "Gx1", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
|
||||
};
|
||||
var gxDevice = new Device { DeviceId = "dev-3", DriverInstanceId = "drv-gx", Name = "gx", DeviceConfig = "{}" };
|
||||
// An upstream array variable committed from the /raw browser: the commit sets the DataType but never
|
||||
// isArray, so it materialises SCALAR while the driver publishes an array into it (R6 MINOR-1).
|
||||
var ua = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-ua", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||
Name = "Ua1", DriverType = "OpcUaClient", DriverConfig = "{}",
|
||||
};
|
||||
var uaDevice = new Device { DeviceId = "dev-4", DriverInstanceId = "drv-ua", Name = "up", DeviceConfig = "{}" };
|
||||
var vector = new Tag
|
||||
{
|
||||
TagId = "t-vector", DeviceId = "dev-4", Name = "Vector", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = "{\"nodeId\":\"ns=2;s=Vector\"}",
|
||||
};
|
||||
var recipe = new Tag
|
||||
{
|
||||
TagId = "t-recipe", DeviceId = "dev-3", Name = "Recipe", DataType = "Int32",
|
||||
@@ -103,9 +118,9 @@ public sealed class ArrayWriteWireTests
|
||||
var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" };
|
||||
|
||||
var composition = AddressSpaceComposer.Compose(
|
||||
[area], [line], [equip], [s7, ab, gx], Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint],
|
||||
rawFolders: [folder], devices: [plc1, plc2, gxDevice]);
|
||||
[area], [line], [equip], [s7, ab, gx, ua], Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint, vector],
|
||||
rawFolders: [folder], devices: [plc1, plc2, gxDevice, uaDevice]);
|
||||
|
||||
var applier = new AddressSpaceApplier(rig.Sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
applier.MaterialiseHierarchy(composition).ShouldBe(0);
|
||||
@@ -118,6 +133,7 @@ public sealed class ArrayWriteWireTests
|
||||
rig.Sink.WriteValue(AbArrayRaw, new[] { 5, 6, 7, 8 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
rig.Sink.WriteValue(GalaxyArrayRaw, new[] { 11, 12 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
rig.Sink.WriteValue(S7ScalarRaw, (short)5, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
rig.Sink.WriteValue(UaScalarRaw, new[] { 1, 2 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
return rig;
|
||||
}
|
||||
|
||||
@@ -247,9 +263,10 @@ public sealed class ArrayWriteWireTests
|
||||
|
||||
/// <summary>
|
||||
/// #610 review MAJOR-1 — the SDK runs no type check of its own on a node with an <c>OnWriteValue</c> handler,
|
||||
/// so the node manager must refuse a mistyped array before dispatch: an <c>Int32[]</c> to an S7 Int16 array
|
||||
/// (else the node serves an int[] under an Int16 DataType), and a <c>UInt32[]</c> to a Galaxy Int32 array
|
||||
/// (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as -294,967,296, Good).
|
||||
/// so the node manager must refuse an array its node type cannot represent before dispatch: an <c>Int32[]</c>
|
||||
/// element outside Int16 to an S7 Int16 array, and a <c>UInt32[]</c> element above Int32.MaxValue to a Galaxy
|
||||
/// Int32 array (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as
|
||||
/// -294,967,296, Good).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_mistyped_array_write_is_refused_inline_and_never_reaches_the_driver()
|
||||
@@ -264,7 +281,7 @@ public sealed class ArrayWriteWireTests
|
||||
|
||||
(await ReadAccessLevelAsync(session, galaxy, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite);
|
||||
|
||||
(await WriteAsync(session, s7, new[] { 1, 2, 3, 4 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "Int32[] to an Int16 array");
|
||||
(await WriteAsync(session, s7, new[] { 1, 2, 3, 70_000 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "an Int32 element outside Int16");
|
||||
(await WriteAsync(session, galaxy, new uint[] { 4_000_000_000, 1 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "UInt32[] to an Int32 array");
|
||||
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 1, 2, 3, 4 });
|
||||
((int[])(await session.ReadValueAsync(galaxy, ct)).Value).ShouldBe(new[] { 11, 12 });
|
||||
@@ -275,7 +292,53 @@ public sealed class ArrayWriteWireTests
|
||||
lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 21, 22 });
|
||||
}
|
||||
|
||||
/// <summary>#610 review MINOR-5 — an array value to a SCALAR node is refused before dispatch.</summary>
|
||||
/// <summary>
|
||||
/// #610 review R6 MAJOR-1 — ScadaBridge writes the CLR list a List attribute decodes to (here Int32, the only
|
||||
/// integer element type it has) to an Int16 array node. The write is converted, checked, to the node's
|
||||
/// element type: the client sees Good, the node serves <c>short[]</c>, and the driver receives <c>short[]</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_numeric_array_of_another_element_type_is_converted_to_the_node_type()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await StartAsync("array-convert", ct);
|
||||
var gateway = new RecordingWriteGateway(succeed: true);
|
||||
rig.Nm.NodeWriteGateway = gateway;
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var s7 = Raw(session, S7ArrayRaw);
|
||||
|
||||
(await WriteAsync(session, s7, new[] { 10, -20, 30, 32_767 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 10, -20, 30, 32_767 });
|
||||
lock (gateway.Writes)
|
||||
{
|
||||
var dispatched = gateway.Writes.ShouldHaveSingleItem().Value.ShouldBeOfType<short[]>();
|
||||
dispatched.ShouldBe(new short[] { 10, -20, 30, 32_767 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #610 review R6 MINOR-1 — a pass-through driver's scalar-shaped node (an upstream array variable committed
|
||||
/// from <c>/raw</c> without <c>isArray</c>) still dispatches an array write, as it did before #610: the
|
||||
/// upstream server arbitrates the shape. The S7 scalar's refusal is the other side of the same rule.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_array_value_to_a_pass_through_scalar_node_is_still_dispatched()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await StartAsync("array-to-ua-scalar", ct);
|
||||
var gateway = new RecordingWriteGateway(succeed: true);
|
||||
rig.Nm.NodeWriteGateway = gateway;
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
|
||||
(await WriteAsync(session, Raw(session, UaScalarRaw), new[] { 7, 8, 9 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||
lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 7, 8, 9 });
|
||||
|
||||
(await WriteAsync(session, Raw(session, S7ScalarRaw), new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
gateway.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>#610 review MINOR-5 — an array value to a SCALAR node of a driver whose scalar encoder cannot take
|
||||
/// one (S7) is refused before dispatch.</summary>
|
||||
[Fact]
|
||||
public async Task An_array_value_to_a_scalar_node_is_refused_inline()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user