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
@@ -6,27 +6,78 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Gitea #610 — the per-driver array-write capability table. The table's agreement with each listed driver's
/// real encoder is pinned in that driver's own test project (<c>*ArrayWriteSupportParityTests</c>); these
/// tests pin the table's own reading of TagConfig (defaults, numeric enums, malformed blobs).
/// tests pin the table's own reading of TagConfig (defaults, numeric enums, malformed blobs, declared length,
/// write caps).
/// </summary>
public sealed class DriverArrayWriteSupportTests
{
private const string Arr2 = ",\"isArray\":true,\"arrayLength\":2";
[Theory]
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\"}", true)]
[InlineData("{\"region\":\"Coils\",\"address\":0}", true)] // FC15 ignores dataType
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Float32\"}", true)]
[InlineData("{\"region\":\"holdingregisters\",\"address\":0,\"dataType\":\"bcd16\"}", true)] // case-insensitive, like the driver
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0}", true)] // dataType ⇒ Int16
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Bool\"}", false)]
[InlineData("{\"region\":\"InputRegisters\",\"address\":0,\"dataType\":\"Int16\"}", false)] // read-only region
[InlineData("{\"region\":\"DiscreteInputs\",\"address\":0}", false)]
[InlineData("{\"region\":0,\"address\":0,\"dataType\":\"Int16\"}", true)] // numeric ⇒ absent ⇒ HoldingRegisters (driver reads it that way)
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\"" + Arr2 + "}", true)]
[InlineData("{\"region\":\"Coils\",\"address\":0" + Arr2 + "}", true)] // FC15 ignores dataType
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Float32\"" + Arr2 + "}", true)]
[InlineData("{\"region\":\"holdingregisters\",\"address\":0,\"dataType\":\"bcd16\"" + Arr2 + "}", true)] // case-insensitive, like the driver
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0" + Arr2 + "}", true)] // dataType ⇒ Int16
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"" + Arr2 + "}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"" + Arr2 + "}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Bool\"" + Arr2 + "}", false)]
[InlineData("{\"region\":\"InputRegisters\",\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // read-only region
[InlineData("{\"region\":\"DiscreteInputs\",\"address\":0" + Arr2 + "}", false)]
[InlineData("{\"region\":0,\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", true)] // numeric ⇒ absent ⇒ HoldingRegisters (driver reads it that way)
[InlineData("{\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // no region ⇒ the factory refuses the tag
[InlineData("{\"region\":\"HoldingRegisters\",\"dataType\":\"Int16\"" + Arr2 + "}", false)] // no address ⇒ likewise
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true}", false)] // no length ⇒ scalar to the driver
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":0}", false)]
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":\"4\"}", false)] // non-numeric length
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":1}", true)] // 1-element array is valid
[InlineData("not json", false)]
[InlineData("", false)]
public void Modbus_rows_follow_region_and_element_type(string tagConfig, bool expected) =>
public void Modbus_rows_follow_region_element_type_and_declared_length(string tagConfig, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBe(expected);
/// <summary>#610 review MINOR-1 — the driver refuses (does not chunk) an FC16 over <c>maxRegistersPerWrite</c>
/// (default 123), so an array whose registers cannot fit is never advertised writable.</summary>
[Theory]
[InlineData("Int16", 123, true)]
[InlineData("Int16", 124, false)]
[InlineData("Int32", 61, true)] // 122 registers
[InlineData("Int32", 62, false)] // 124 registers
[InlineData("Float64", 30, true)] // 120 registers
[InlineData("Float64", 31, false)] // 124 registers
public void Modbus_register_arrays_respect_the_default_write_cap(string dataType, int length, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray(dataType, length)).ShouldBe(expected);
/// <summary>A configured cap (driver- or device-level, case-insensitive) is honoured; when both are visible the
/// smaller wins, and a non-positive value means the driver default.</summary>
[Fact]
public void Modbus_register_arrays_respect_a_configured_write_cap()
{
var tag = RegisterArray("Int16", 10);
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":10}").ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"MaxRegistersPerWrite\":9}").ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":100}", "{\"maxRegistersPerWrite\":9}")
.ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":0}", null, "not json").ShouldBeTrue();
// A larger configured cap lifts the default: 124 registers fit under 200.
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 124), "{\"maxRegistersPerWrite\":200}")
.ShouldBeTrue();
}
/// <summary>FC15 carries at most 1968 coils and the driver sends one request, so a longer coil array is not
/// writable.</summary>
[Fact]
public void Modbus_coil_arrays_respect_the_FC15_ceiling()
{
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean",
"{\"region\":\"Coils\",\"address\":0,\"isArray\":true,\"arrayLength\":1968}").ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean",
"{\"region\":\"Coils\",\"address\":0,\"isArray\":true,\"arrayLength\":1969}").ShouldBeFalse();
}
private static string RegisterArray(string dataType, int length) =>
$"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"{dataType}\",\"isArray\":true,\"arrayLength\":{length}}}";
[Theory]
[InlineData("Byte", true)]
[InlineData("Int16", true)]
@@ -41,12 +92,23 @@ public sealed class DriverArrayWriteSupportTests
[InlineData("String", false)]
[InlineData("DateTime", false)]
public void S7_rows_follow_the_element_type(string s7Type, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"{s7Type}\"}}")
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"{s7Type}\"{Arr2}}}")
.ShouldBe(expected);
[Fact]
public void S7_without_a_dataType_key_is_not_an_S7_tag() =>
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", "{\"address\":\"DB1.DBW0\"}").ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", "{\"address\":\"DB1.DBW0\"" + Arr2 + "}").ShouldBeFalse();
/// <summary>#610 review MINOR-2 — S7 maps isArray without a positive integer arrayLength to a SCALAR tag.</summary>
[Theory]
[InlineData("", false)]
[InlineData(",\"isArray\":true", false)]
[InlineData(",\"isArray\":true,\"arrayLength\":0", false)]
[InlineData(",\"isArray\":false,\"arrayLength\":4", false)]
[InlineData(",\"isArray\":true,\"arrayLength\":4", true)]
public void S7_requires_a_declared_length(string shape, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"{shape}}}")
.ShouldBe(expected);
[Theory]
[InlineData("Boolean", true)]
@@ -60,7 +122,17 @@ public sealed class DriverArrayWriteSupportTests
[InlineData("UInt32", false)]
[InlineData("Byte", false)]
public void Galaxy_rows_follow_the_opc_ua_data_type(string dataType, bool expected) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"}").ShouldBe(expected);
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"" + Arr2 + "}").ShouldBe(expected);
/// <summary>#610 review MINOR-4 — with no declared length the node is unbounded and a short whole-array write
/// would RESIZE the Galaxy attribute, so an undeclared-length Galaxy array stays read-only.</summary>
[Fact]
public void Galaxy_requires_a_declared_length()
{
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", "{\"FullName\":\"Obj.Attr\",\"isArray\":true}").ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", "{\"FullName\":\"Obj.Attr\",\"isArray\":true,\"arrayLength\":0}").ShouldBeFalse();
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", "{\"FullName\":\"Obj.Attr\",\"isArray\":true,\"arrayLength\":3}").ShouldBeTrue();
}
[Fact]
public void OpcUaClient_forwards_every_array() =>
@@ -73,15 +145,15 @@ public sealed class DriverArrayWriteSupportTests
var listed = new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy };
var others = DriverTypeNames.All.Except(listed).ToList();
others.ShouldNotBeEmpty();
const string blob = "{\"region\":\"Coils\",\"dataType\":\"Int32\"}";
// A blob every listed driver would accept, so only the driver type can make the answer false.
const string blob = "{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int32\"" + Arr2 + "}";
// Positive control: the same blob IS array-writable under the listed drivers that read it.
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32", blob).ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int32", blob).ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", blob).ShouldBeTrue();
foreach (var driverType in others.Append("NoSuchDriver").Append(null))
{
// A blob every listed driver would accept, so only the driver type can make the answer false.
DriverArrayWriteSupport.Supports(driverType, "Int32", "{\"region\":\"Coils\",\"dataType\":\"Int32\"}")
.ShouldBeFalse(driverType ?? "<null>");
DriverArrayWriteSupport.Supports(driverType, "Int32", blob).ShouldBeFalse(driverType ?? "<null>");
}
}
}
@@ -7,10 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Gitea #610 — pins <see cref="DriverArrayWriteSupport"/>'s Galaxy row against the REAL write encoder
/// (<see cref="MxValueEncoder"/>). The OPC UA server hands the driver the CLR array type of the node's
/// <c>DataType</c> (the SDK refuses a mistyped array before the write handler runs), so for every
/// <c>Tag.DataType</c> the server can materialise, the node is advertised writable exactly when the encoder
/// accepts that array type.
/// (<see cref="MxValueEncoder"/>). The driver receives the CLR array type of the node's <c>DataType</c> and no
/// other: NOT because the SDK checks it (it skips its own type check on a node with an <c>OnWriteValue</c>
/// handler, which every writable equipment node has) but because <c>OtOpcUaNodeManager.EvaluateEquipmentWriteStructure</c>
/// refuses an array whose element type does not match the node's DataType exactly (#610 review MAJOR-1). So
/// for every <c>Tag.DataType</c> the server can materialise, the node is advertised writable exactly when the
/// encoder accepts that array type.
/// </summary>
[Trait("Category", "Unit")]
public sealed class GalaxyArrayWriteSupportParityTests
@@ -50,14 +52,18 @@ public sealed class GalaxyArrayWriteSupportParityTests
encoderAccepts = false;
}
// UInt32 / UInt64 are a deliberate exclusion: CLR array covariance lets uint[] / ulong[] match the encoder's
// int[] / long[] arms, so it "accepts" them by REINTERPRETING the bits (uint.MaxValue → -1) — unlike the
// scalar arms, which range-check. A Galaxy attribute is never unsigned (DataTypeMap), so the table refuses
// rather than advertise a write that silently corrupts large values.
var expected = encoderAccepts && dataType is not ("UInt32" or "UInt64");
if (dataType is "UInt32" or "UInt64") encoderAccepts.ShouldBeTrue("the covariance hazard this exclusion documents");
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"}")
.ShouldBe(expected, dataType);
// UInt32 / UInt64 used to be a hand-written exclusion here, because CLR array covariance let uint[] /
// ulong[] match the encoder's int[] / long[] arms and be encoded by reinterpreting their bits. The encoder
// now refuses them itself, so the table and the encoder agree with no special case.
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\",\"isArray\":true,\"arrayLength\":2}")
.ShouldBe(encoderAccepts, dataType);
}
/// <summary>Positive control: the theory above sees both answers, so it is not vacuously all-refused.</summary>
[Fact]
public void The_matrix_contains_both_answers() =>
ClrArrays.Keys
.Select(dt => DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dt, "{\"isArray\":true,\"arrayLength\":2}"))
.ToHashSet()
.ShouldBe(new[] { true, false }, ignoreOrder: true);
}
@@ -121,6 +121,21 @@ public sealed class MxValueEncoderTests
v.ArrayValue.TimestampValues.Values[0].ToDateTime().ShouldBe(inputs[0]);
}
/// <summary>
/// Gitea #610 review — CLR array covariance makes <c>uint[] is int[]</c> and <c>ulong[] is long[]</c> true,
/// so the int[]/long[] arms used to encode an unsigned array by reinterpreting its bits (4,000,000,000 →
/// -294,967,296). An unsigned array is refused instead, like an out-of-range unsigned scalar.
/// </summary>
[Fact]
public void Encode_UnsignedArrays_AreRefused_NotReinterpreted()
{
Should.Throw<ArgumentException>(() => MxValueEncoder.Encode(new uint[] { 4_000_000_000 }));
Should.Throw<ArgumentException>(() => MxValueEncoder.Encode(new ulong[] { ulong.MaxValue }));
// Positive control: the signed arrays still encode.
MxValueEncoder.Encode(new[] { -1 }).ArrayValue.Int32Values.Values.ShouldBe(new[] { -1 });
MxValueEncoder.Encode(new[] { -1L }).ArrayValue.Int64Values.Values.ShouldBe(new[] { -1L });
}
/// <summary>Verifies that encoding an unsupported type throws an exception.</summary>
[Fact]
public void Encode_UnsupportedType_Throws()
@@ -51,8 +51,54 @@ public sealed class ModbusArrayWriteSupportParityTests
var answers = Shapes()
.Select(s => DriverArrayWriteSupport.Supports(
DriverTypeNames.Modbus, "Int16",
$$"""{"region":"{{s.Region}}","address":10,"dataType":"{{s.DataType}}"}"""))
$$"""{"region":"{{s.Region}}","address":10,"dataType":"{{s.DataType}}","isArray":true,"arrayLength":2}"""))
.ToHashSet();
answers.ShouldBe(new[] { true, false }, ignoreOrder: true);
}
/// <summary>
/// #610 review MINOR-1/-2 — the table must also agree with the driver on the SHAPE edges: the FC16 write cap
/// (the driver refuses rather than chunks an over-cap array; default 123 registers, or the configured
/// <c>MaxRegistersPerWrite</c>) and a missing / zero <c>arrayLength</c> (the factory maps it to a scalar tag,
/// whose scalar encoder refuses an array value).
/// </summary>
[Theory]
[InlineData("Int32", "\"arrayLength\":61", 0, 61)] // 122 registers — fits the default cap
[InlineData("Int32", "\"arrayLength\":62", 0, 62)] // 124 registers — over it
[InlineData("Float64", "\"arrayLength\":31", 0, 31)] // 124 registers
[InlineData("Int16", "\"arrayLength\":10", 10, 10)] // exactly a configured cap
[InlineData("Int16", "\"arrayLength\":11", 10, 11)] // one over it
[InlineData("Int16", "\"arrayLength\":0", 0, 2)] // declared length 0 ⇒ scalar to the driver
[InlineData("Int16", "\"isArrayLengthMissing\":true", 0, 2)]
public async Task Table_agrees_with_the_driver_on_write_cap_and_declared_length(
string dataType, string lengthProp, ushort maxRegistersPerWrite, int elements)
{
var json = $$"""{"region":"HoldingRegisters","address":10,"dataType":"{{dataType}}","isArray":true,{{lengthProp}}}""";
var driverConfig = maxRegistersPerWrite == 0 ? "{}" : $$"""{"maxRegistersPerWrite":{{maxRegistersPerWrite}}}""";
var fake = new ModbusDriverTests.FakeTransport();
var options = new ModbusDriverOptions
{
Host = "fake", RawTags = [new RawTagEntry("cell/modbus/arr", json, false)],
MaxRegistersPerWrite = maxRegistersPerWrite == 0 ? (ushort)123 : maxRegistersPerWrite,
};
var drv = new ModbusDriver(options, "modbus-array-cap-parity", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
var value = Enumerable.Repeat<object>(1, elements).ToArray();
var result = await drv.WriteAsync([new WriteRequest("cell/modbus/arr", value)], CancellationToken.None);
var driverAccepts = result.Single().StatusCode == 0u;
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json, driverConfig).ShouldBe(
driverAccepts, $"{dataType} {lengthProp} cap={maxRegistersPerWrite}: driver status 0x{result.Single().StatusCode:X8}");
}
/// <summary>Positive control for the theory above: both answers occur, so it is not vacuously all-refused.</summary>
[Fact]
public void The_shape_edges_contain_both_answers()
{
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32",
"""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":61}""").ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32",
"""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":62}""").ShouldBeFalse();
}
}
@@ -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);
}
}
@@ -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()