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
@@ -39,8 +39,13 @@ internal static class MxValueEncoder
case DateTimeOffset dto: return new MxValue { TimestampValue = Timestamp.FromDateTimeOffset(dto) };
case bool[] arr: return EncodeArray(arr, (mx, vs) => mx.BoolValues = ToBoolArray(vs));
case int[] arr: return EncodeArray(arr, (mx, vs) => mx.Int32Values = ToInt32Array(vs));
case long[] arr: return EncodeArray(arr, (mx, vs) => mx.Int64Values = ToInt64Array(vs));
// Exact-type guards: CLR array covariance makes `uint[] is int[]` and `ulong[] is long[]` true, so
// without them an unsigned array would be encoded by REINTERPRETING its bits (uint.MaxValue → -1) —
// unlike the range-checked scalar arms above. An unsigned array is refused instead (Gitea #610 review).
case int[] arr when arr.GetType() == typeof(int[]):
return EncodeArray(arr, (mx, vs) => mx.Int32Values = ToInt32Array(vs));
case long[] arr when arr.GetType() == typeof(long[]):
return EncodeArray(arr, (mx, vs) => mx.Int64Values = ToInt64Array(vs));
case float[] arr: return EncodeArray(arr, (mx, vs) => mx.FloatValues = ToFloatArray(vs));
case double[] arr: return EncodeArray(arr, (mx, vs) => mx.DoubleValues = ToDoubleArray(vs));
case string[] arr: return EncodeArray(arr, (mx, vs) => mx.StringValues = ToStringArray(vs));