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:
Joseph Doherty
2026-10-08 08:07:36 -04:00
parent b7a48e2b93
commit dabd8eb955
14 changed files with 532 additions and 85 deletions
@@ -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);
}
}