fix(address-space): exact element-type check for array writes + array-capability shape edges (#610 review R4)
MAJOR-1: the SDK skips TypeInfo.IsInstanceOfDataType on any node with an OnWriteValue handler (every writable equipment node), and the node manager's fail-fast treated numeric<->numeric as compatible, so a UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR covariance and was written bit-reinterpreted with a Good result. EvaluateEquipmentWriteStructure now matches an ARRAY write's element type exactly (Byte[]/ByteString the only equivalence; Variant[]/object[] refused) -> BadTypeMismatch, nothing dispatched. MxValueEncoder's int[]/long[] arms are exact-type guarded too, and the Galaxy parity test's false "the SDK refuses" premise is corrected (the UInt32/UInt64 exclusion is now the encoder's own answer). Scalar writes keep numeric widening by decision: consumers write CLR-typed values (Double to a Float node) and every driver's scalar encoder range-checks. MINOR: Modbus row honours maxRegistersPerWrite (smallest of DriverConfig / the tag's DeviceConfig, default 123) and FC15's 1968-coil ceiling, and requires region + numeric address; Modbus/S7/Galaxy require isArray + integer arrayLength >= 1 (Galaxy: an unbounded node would let a short write resize the attribute); an array value to a scalar node is refused inline. Docs: Uns.md array-write rules + upgrade note (ReadWrite array tags become client-writable on first boot), Modbus.md, Galaxy.md. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -32,6 +32,8 @@ public sealed class ArrayWriteWireTests
|
||||
private const string S7ArrayRaw = "Plant/S7a/plc1/Levels";
|
||||
private const string AbArrayRaw = "Plant/Ab1/plc2/Counts";
|
||||
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 sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
|
||||
{
|
||||
@@ -65,6 +67,24 @@ public sealed class ArrayWriteWireTests
|
||||
};
|
||||
var plc1 = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-s7", Name = "plc1", DeviceConfig = "{}" };
|
||||
var plc2 = new Device { DeviceId = "dev-2", DriverInstanceId = "drv-ab", Name = "plc2", DeviceConfig = "{}" };
|
||||
var gx = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-gx", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||
Name = "Gx1", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
|
||||
};
|
||||
var gxDevice = new Device { DeviceId = "dev-3", DriverInstanceId = "drv-gx", Name = "gx", DeviceConfig = "{}" };
|
||||
var recipe = new Tag
|
||||
{
|
||||
TagId = "t-recipe", DeviceId = "dev-3", Name = "Recipe", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = "{\"FullName\":\"Mixer_001.Recipe\",\"isArray\":true,\"arrayLength\":2}",
|
||||
};
|
||||
var setpoint = new Tag
|
||||
{
|
||||
TagId = "t-setpoint", DeviceId = "dev-1", Name = "Setpoint", DataType = "Int16",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = "{\"address\":\"DB1.DBW20\",\"dataType\":\"Int16\"}",
|
||||
};
|
||||
var levels = new Tag
|
||||
{
|
||||
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", DataType = "Int16",
|
||||
@@ -83,9 +103,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], Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: [reference], tags: [levels, counts],
|
||||
rawFolders: [folder], devices: [plc1, plc2]);
|
||||
[area], [line], [equip], [s7, ab, gx], Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint],
|
||||
rawFolders: [folder], devices: [plc1, plc2, gxDevice]);
|
||||
|
||||
var applier = new AddressSpaceApplier(rig.Sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
applier.MaterialiseHierarchy(composition).ShouldBe(0);
|
||||
@@ -96,6 +116,8 @@ public sealed class ArrayWriteWireTests
|
||||
rig.Sink.WriteValue(S7ArrayRaw, new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
rig.Sink.WriteValue(S7ArrayUns, new short[] { 1, 2, 3, 4 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
|
||||
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);
|
||||
return rig;
|
||||
}
|
||||
|
||||
@@ -222,4 +244,54 @@ public sealed class ArrayWriteWireTests
|
||||
(await WriteAsync(session, raw, new short[] { 7, 7, 7, 7 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||
gateway.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_mistyped_array_write_is_refused_inline_and_never_reaches_the_driver()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await StartAsync("array-type", 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);
|
||||
var galaxy = Raw(session, GalaxyArrayRaw);
|
||||
|
||||
(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, 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 });
|
||||
gateway.Count.ShouldBe(0);
|
||||
|
||||
// Positive control: the correctly-typed array on the same Galaxy node is dispatched as-is.
|
||||
(await WriteAsync(session, galaxy, new[] { 21, 22 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||
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>
|
||||
[Fact]
|
||||
public async Task An_array_value_to_a_scalar_node_is_refused_inline()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await StartAsync("array-to-scalar", ct);
|
||||
var gateway = new RecordingWriteGateway(succeed: true);
|
||||
rig.Nm.NodeWriteGateway = gateway;
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var scalar = Raw(session, S7ScalarRaw);
|
||||
|
||||
(await WriteAsync(session, scalar, new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
((short)(await session.ReadValueAsync(scalar, ct)).Value).ShouldBe((short)5);
|
||||
gateway.Count.ShouldBe(0);
|
||||
|
||||
// Positive control: a scalar write to the same node is dispatched.
|
||||
(await WriteAsync(session, scalar, (short)9, ct)).ShouldBe((StatusCode)StatusCodes.Good);
|
||||
gateway.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -307,4 +307,36 @@ public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
|
||||
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>#610 review MINOR-1 — the composer hands the table the owning driver's <c>DriverConfig</c> AND the
|
||||
/// tag's own device's <c>DeviceConfig</c>, so a Modbus write cap configured at either level keeps an array that
|
||||
/// can never fit read-only — on the raw node and its UNS mirror alike. The same tag under an uncapped device is
|
||||
/// the positive control.</summary>
|
||||
[Fact]
|
||||
public void Array_write_capability_honours_a_modbus_write_cap_from_driver_or_device_config()
|
||||
{
|
||||
var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" };
|
||||
var mb = new DriverInstance { DriverInstanceId = "drv-mb", ClusterId = "c1", RawFolderId = "f", Name = "Mb", DriverType = "Modbus", DriverConfig = "{\"MaxRegistersPerWrite\":100}" };
|
||||
var open = new Device { DeviceId = "d-open", DriverInstanceId = "drv-mb", Name = "open", DeviceConfig = "{}" };
|
||||
var capped = new Device { DeviceId = "d-capped", DriverInstanceId = "drv-mb", Name = "capped", DeviceConfig = "{\"maxRegistersPerWrite\":8}" };
|
||||
static Tag Arr(string id, string device, int length) => new()
|
||||
{
|
||||
TagId = id, DeviceId = device, Name = id, DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = $"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":{length}}}",
|
||||
};
|
||||
var tags = new[] { Arr("fits", "d-open", 100), Arr("overDriverCap", "d-open", 101), Arr("overDeviceCap", "d-capped", 10) };
|
||||
var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" };
|
||||
var refs = new[] { new UnsTagReference { UnsTagReferenceId = "r", EquipmentId = "e", TagId = "overDeviceCap" } };
|
||||
|
||||
var c = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip }, new[] { mb }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { open, capped });
|
||||
|
||||
c.RawTags.Single(t => t.Name == "fits").ArrayWritable.ShouldBeTrue();
|
||||
c.RawTags.Single(t => t.Name == "overDriverCap").ArrayWritable.ShouldBeFalse();
|
||||
c.RawTags.Single(t => t.Name == "overDeviceCap").ArrayWritable.ShouldBeFalse();
|
||||
c.UnsReferenceVariables.Single().BackingArrayWritable.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +129,10 @@ public sealed class EquipmentWriteGateTests
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: 42, node).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>(A3) Numeric-to-numeric is treated as compatible (the SDK widens/narrows numerics) — a
|
||||
/// Double payload into an Int32 node proceeds rather than being rejected here.</summary>
|
||||
/// <summary>(A3) SCALAR numeric-to-numeric is treated as compatible — a Double payload into an Int32 node
|
||||
/// proceeds rather than being rejected here. Deliberately looser than the SDK (which never sees these writes:
|
||||
/// it skips its type check when OnWriteValue is set): consumers write CLR-typed values, and every driver's
|
||||
/// scalar encoder converts with a range check. Arrays are matched exactly instead (#610 review).</summary>
|
||||
[Fact]
|
||||
public void Structure_numeric_to_numeric_proceeds()
|
||||
{
|
||||
@@ -211,6 +213,54 @@ public sealed class EquipmentWriteGateTests
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1, 2, 3 }, node).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>#610 review MAJOR-1 — the SDK skips its own type check when <c>OnWriteValue</c> is set, so an array
|
||||
/// write's ELEMENT type must match the node's DataType exactly here: no numeric widening for arrays (a
|
||||
/// <c>uint[]</c> to an Int32 node would reach MxValueEncoder's <c>int[]</c> arm by covariance and reinterpret
|
||||
/// its bits). The matching element type is the positive control.</summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(MistypedArrays))]
|
||||
public void Structure_array_node_requires_the_exact_element_type(NodeId dataType, Array matching, Array mistyped)
|
||||
{
|
||||
var node = ArrayNode(dataType, length: 2);
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(matching, node).ShouldBeNull();
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(mistyped, node)!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, mistyped.GetType().Name);
|
||||
}
|
||||
|
||||
public static TheoryData<NodeId, Array, Array> MistypedArrays() => new()
|
||||
{
|
||||
{ DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1, 2 } }, // Int32[] → Int16
|
||||
{ DataTypeIds.Int32, new[] { 1, 2 }, new uint[] { 4_000_000_000, 2 } }, // UInt32[] → Int32 (Galaxy)
|
||||
{ DataTypeIds.Int64, new long[] { 1, 2 }, new ulong[] { 1, 2 } }, // UInt64[] → Int64 (Galaxy)
|
||||
{ DataTypeIds.Float, new[] { 1f, 2f }, new[] { 1d, 2d } }, // Double[] → Float
|
||||
{ DataTypeIds.Boolean, new[] { true, false }, new[] { 1, 0 } }, // Int32[] → Boolean
|
||||
{ DataTypeIds.Int16, new short[] { 1, 2 }, new object[] { (short)1, (short)2 } }, // unclassifiable ⇒ refused
|
||||
};
|
||||
|
||||
/// <summary>A Byte array decodes as <c>byte[]</c>, which classifies as ByteString — the one equivalence the SDK
|
||||
/// itself honours for array nodes.</summary>
|
||||
[Fact]
|
||||
public void Structure_byte_array_node_takes_a_byte_array()
|
||||
{
|
||||
var node = ArrayNode(DataTypeIds.Byte, length: 2);
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new byte[] { 1, 2 }, node).ShouldBeNull();
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { 1, 2 }, node)!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
}
|
||||
|
||||
/// <summary>#610 review MINOR-5 — the mirror case: an array value to a scalar node is refused up front. A
|
||||
/// byte[] (a ByteString on the wire) is a scalar and still reaches the type check; an unresolved-DataType node
|
||||
/// defers as before.</summary>
|
||||
[Fact]
|
||||
public void Structure_scalar_node_refuses_an_array_value()
|
||||
{
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1 }, ValueNode(DataTypeIds.Int16))!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure((short)1, ValueNode(DataTypeIds.Int16)).ShouldBeNull();
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new byte[] { 1 }, ValueNode(DataTypeIds.ByteString)).ShouldBeNull();
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new short[] { 1 }, ValueNode(DataTypeIds.BaseDataType)).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A partial (IndexRange) write is refused; an absent range proceeds.</summary>
|
||||
[Fact]
|
||||
public void Range_only_whole_value_writes_proceed()
|
||||
|
||||
Reference in New Issue
Block a user