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
@@ -20,28 +20,46 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// until this table follows.
/// </para>
/// <list type="bullet">
/// <item><b>Modbus</b> — <c>region: Coils</c> (FC15, any element) and <c>region: HoldingRegisters</c>
/// with a fixed-width numeric <c>dataType</c> (FC16). Bit-in-register, String and Bool-in-register
/// arrays have no array encoder; input regions are read-only.</item>
/// <item><b>Modbus</b> — <c>region: Coils</c> (FC15, any element, at most 1968 coils) and
/// <c>region: HoldingRegisters</c> with a fixed-width numeric <c>dataType</c> (FC16) whose
/// <c>arrayLength × registers-per-element</c> fits the driver's <c>maxRegistersPerWrite</c> (default
/// 123 — the driver refuses rather than chunks a longer FC16). Bit-in-register, String and
/// Bool-in-register arrays have no array encoder; input regions are read-only.</item>
/// <item><b>S7</b> — <c>dataType</c> Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (one contiguous
/// block, <c>EncodeArrayBlock</c>). Bool arrays would clobber neighbouring bits; the wide / String /
/// DateTime element types have no array encoder.</item>
/// <item><b>OpcUaClient</b> — every element type: the value is forwarded to the upstream server as a
/// <c>Variant</c> unchanged, and the upstream server is the arbiter.</item>
/// <c>Variant</c> unchanged, and the upstream server is the arbiter (of shape, too).</item>
/// <item><b>Galaxy</b> — the array variants <c>MxValueEncoder</c> encodes, keyed by the OPC UA
/// <c>DataType</c> the node is created with: Boolean / Int32 / Int64 / Float / Double / String /
/// DateTime.</item>
/// DateTime — and only with a declared <c>arrayLength</c>: an undeclared length materialises an
/// unbounded node, and a short whole-array write to it would RESIZE the Galaxy attribute (the
/// mxaccessgw <c>gateway.md</c> contract), which is never what a client meant.</item>
/// <item>Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar
/// write encoders only; array nodes stay read-only.</item>
/// </list>
/// <para>Modbus, S7 and Galaxy require a declared length — <c>isArray: true</c> AND an integer
/// <c>arrayLength &gt;= 1</c>. The Modbus and S7 tag factories treat anything else as a SCALAR tag (whose
/// scalar encoder cannot take an array value), though the composer still materialises an array node.</para>
/// <para>Multi-dimensional arrays (<c>ValueRank &gt; 1</c>) are out of scope altogether — every array node
/// is materialised 1-D (<c>ValueRank = OneDimension</c>), so nothing here considers more than one rank.</para>
/// </summary>
public static class DriverArrayWriteSupport
{
private static readonly HashSet<string> ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
/// <summary>The Modbus driver's FC16 write cap when <c>maxRegistersPerWrite</c> is absent or <c>0</c>
/// (<c>ModbusDriver</c> resolves it the same way).</summary>
public const int ModbusDefaultMaxRegistersPerWrite = 123;
/// <summary>Modbus FC15's protocol ceiling on coils per request (0x07B0). The driver sends one FC15 for the
/// whole array without chunking, so a longer coil array is refused by every conformant device.</summary>
public const int ModbusMaxCoilsPerWrite = 1968;
// Element type → registers per element (ModbusDriver.RegisterCount).
private static readonly Dictionary<string, int> ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
{
"Int16", "UInt16", "Bcd16", "Int32", "UInt32", "Bcd32", "Float32", "Int64", "UInt64", "Float64",
["Int16"] = 1, ["UInt16"] = 1, ["Bcd16"] = 1,
["Int32"] = 2, ["UInt32"] = 2, ["Bcd32"] = 2, ["Float32"] = 2,
["Int64"] = 4, ["UInt64"] = 4, ["Float64"] = 4,
};
private static readonly HashSet<string> S7ArrayTypes = new(StringComparer.OrdinalIgnoreCase)
@@ -61,31 +79,43 @@ public static class DriverArrayWriteSupport
/// constant); <see langword="null"/> or unknown ⇒ <see langword="false"/>.</param>
/// <param name="dataType">The tag's OPC UA <c>DataType</c> name (<c>Tag.DataType</c>).</param>
/// <param name="tagConfig">The tag's <c>TagConfig</c> JSON (driver-native address + element type).</param>
/// <param name="driverConfigs">The <c>DriverConfig</c> / <c>DeviceConfig</c> JSON blobs that can carry a
/// driver write cap (Modbus <c>maxRegistersPerWrite</c>). Every one is consulted and the SMALLEST cap
/// wins, so the answer stays conservative however the driver's config merge resolves them.</param>
/// <returns><see langword="true"/> only when the driver encodes a whole-array write for this tag.</returns>
public static bool Supports(string? driverType, string? dataType, string? tagConfig)
public static bool Supports(string? driverType, string? dataType, string? tagConfig, params string?[] driverConfigs)
{
switch (driverType)
{
case DriverTypeNames.OpcUaClient:
return true;
case DriverTypeNames.Galaxy:
return dataType is not null && GalaxyArrayDataTypes.Contains(dataType);
return dataType is not null && GalaxyArrayDataTypes.Contains(dataType)
&& TryParseObject(tagConfig, out var galaxyRoot) && ReadArrayLength(galaxyRoot) is not null;
case DriverTypeNames.Modbus:
{
// Same reads as ModbusTagDefinitionFactory: an absent (or non-string) region defaults to
// HoldingRegisters and an absent dataType to Int16 (TagConfigJson.TryReadEnumStrict).
if (!TryParseObject(tagConfig, out var root)) return false;
// Same reads as ModbusTagDefinitionFactory: a blob with no `region` or no numeric `address` is not
// a Modbus tag at all (the factory refuses it). A present non-string region is read as absent ⇒
// HoldingRegisters, and an absent dataType ⇒ Int16 (TagConfigJson.TryReadEnumStrict).
if (!TryParseObject(tagConfig, out var root)
|| !root.TryGetProperty("region", out _)
|| !root.TryGetProperty("address", out var address) || address.ValueKind != JsonValueKind.Number
|| ReadArrayLength(root) is not { } count)
return false;
var region = ReadEnumName(root, "region") ?? "HoldingRegisters";
var native = ReadEnumName(root, "dataType") ?? "Int16";
if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return true;
if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return count <= ModbusMaxCoilsPerWrite;
return string.Equals(region, "HoldingRegisters", StringComparison.OrdinalIgnoreCase)
&& ModbusRegisterArrayTypes.Contains(native);
&& ModbusRegisterArrayTypes.TryGetValue(native, out var registersPerElement)
&& (long)count * registersPerElement <= ModbusMaxRegistersPerWrite(driverConfigs);
}
case DriverTypeNames.S7:
{
// S7TagDefinitionFactory requires a dataType key (absent ⇒ not an S7 tag at all); a present
// non-string value falls back to Int16, exactly as the factory's strict enum read does.
if (!TryParseObject(tagConfig, out var root) || !root.TryGetProperty("dataType", out _)) return false;
if (!TryParseObject(tagConfig, out var root) || !root.TryGetProperty("dataType", out _)
|| ReadArrayLength(root) is null)
return false;
var native = ReadEnumName(root, "dataType") ?? "Int16";
return S7ArrayTypes.Contains(native);
}
@@ -94,6 +124,36 @@ public static class DriverArrayWriteSupport
}
}
// The smallest Modbus write cap configured in any config; none configured (absent / non-positive) ⇒ the driver
// default (123). The key is matched case-insensitively, like the driver's DTO binding.
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs)
{
int? cap = null;
foreach (var config in driverConfigs ?? [])
{
if (!TryParseObject(config, out var root)) continue;
foreach (var prop in root.EnumerateObject())
{
if (string.Equals(prop.Name, "maxRegistersPerWrite", StringComparison.OrdinalIgnoreCase)
&& prop.Value.ValueKind == JsonValueKind.Number
&& prop.Value.TryGetInt32(out var configured) && configured > 0)
{
cap = Math.Min(cap ?? int.MaxValue, configured);
}
}
}
return cap ?? ModbusDefaultMaxRegistersPerWrite;
}
// The declared element count: isArray is the JSON literal true AND arrayLength is an Int32 >= 1 — the rule the
// Modbus and S7 tag factories apply. Anything else is a scalar to those drivers ⇒ null.
private static int? ReadArrayLength(JsonElement root) =>
root.TryGetProperty("isArray", out var isArray) && isArray.ValueKind == JsonValueKind.True
&& root.TryGetProperty("arrayLength", out var length) && length.ValueKind == JsonValueKind.Number
&& length.TryGetInt32(out var count) && count >= 1
? count
: null;
// Parses the TagConfig root object; a blank / malformed / non-object blob is "no tag" ⇒ false. The clone
// detaches the element from the disposed document.
private static bool TryParseObject(string? tagConfig, out JsonElement root)