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:
+19
-13
@@ -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()
|
||||
|
||||
+47
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user