feat(arrays): Modbus String[]/BitInRegister[] array writes reachable; table follows the resolver

The Modbus lane (#741 U3) gave the driver String[] and BitInRegister[] holding-register array encoders, and
moved address resolution to ModbusTagAddressResolution (keys first, a bit region coerced to Bool, strict key
reads), but DriverArrayWriteSupport still listed both array shapes read-only (follow-up F1), so a client
write was BadNotWritable before it reached the driver.

Modbus row now mirrors the driver end to end:
- Keys shape only (region + address both present). A string-only addressString tag is resolved by the
  driver with its own family through a parser Core.Abstractions cannot reference, so it stays read-only
  (pinned: the conservative direction).
- Strict reads as the resolver: address an integer 0-65535; a present region / dataType / byteOrder /
  stringByteOrder string must name a member.
- Coils: any dataType (coerced to Bool), <= 1968 coils (FC15).
- HoldingRegisters (FC16, never Bool): numeric, String ((stringLength+1)/2 registers, stringLength >= 1),
  BitInRegister (1 register, bitIndex 0-15, span ending <= 65535), all within maxRegistersPerWrite.
  FC23 is opt-in and never changes the answer.
- Elements convert (ScadaBridge int[] -> Int16 stays writable); only a String / DateTime node over a
  numeric or bit element is left out, since such an element never converts.

Tests: the parity test's PendingF1 exemption and its guard are gone; the parity matrix is now region x
element type x node type against the real WriteAsync with a spec-conformant device (FC15 > 1968 refused),
plus shape edges at every cap boundary (numeric, String, BitInRegister, coil), strict key reads, FC23
on/off, string-only, the ScadaBridge int[] shape and U3 wire bytes. Core.Abstractions row tests and a
composer test (String[] and BitInRegister[] writable; over-cap, no-stringLength and string-only read-only).
Docs: table XML doc, Uns.md, modbus-addressing.md, drivers/Modbus.md, data-plane rules.

Refs #741

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:10:00 -04:00
parent 92dea29547
commit 5533de6281
8 changed files with 597 additions and 130 deletions
@@ -20,11 +20,20 @@ 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, 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>Modbus</b> — tags addressed by the <c>region</c> + <c>address</c> KEYS (the
/// <c>ModbusTagAddressResolution</c> rule: keys win whenever both are present), read as strictly as the
/// driver reads them (<c>address</c> an integer 0–65535; a present <c>region</c> / <c>dataType</c> /
/// <c>byteOrder</c> / <c>stringByteOrder</c> string must name a member). <c>region: Coils</c>: FC15, at most
/// 1968 coils, with ANY <c>dataType</c> — the resolver coerces a bit region to Bool and never refuses it.
/// <c>region: HoldingRegisters</c> (FC16), never Bool: a fixed-width numeric <c>dataType</c>,
/// <c>String</c> (<c>(stringLength+1)/2</c> registers per element, <c>stringLength &gt;= 1</c>) or
/// <c>BitInRegister</c> (one register per element, read-modify-written; <c>bitIndex</c> 0–15, the span
/// ending at or before register 65535), 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). FC23 is opt-in, capped lower and falls back to FC16, so it never changes the answer. Input
/// regions are read-only. A string-only tag (an <c>addressString</c> with no keys) is resolved by the
/// driver with ITS family through a parser this assembly cannot reference, so it cannot be proven
/// writable here and stays 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>
@@ -61,9 +70,11 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <para><b>Elements convert; they are not exact-matched.</b> ScadaBridge writes CLR arrays such as
/// <c>int[]</c> / <c>List&lt;int&gt;</c> to Int16 nodes; the node manager converts a numeric array to the
/// node's element type, and the AbCip, AbLegacy and TwinCAT encoders then <c>Convert</c> each element to the
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>). So those rows do not key on an
/// exact node <c>DataType</c>; they leave out only the node types whose elements NEVER convert: a
/// <c>DateTime</c> or <c>String</c> node over a numeric element (AbCip, AbLegacy, TwinCAT — TwinCAT keeps
/// controller's type (an element that does not fit is <c>BadOutOfRange</c>); Modbus <c>Convert</c>s each element to
/// the register / coil type the same way. So those rows do not key on an exact node <c>DataType</c>; they leave
/// out only the node types whose elements NEVER convert: a <c>DateTime</c> or <c>String</c> node over a numeric
/// element (Modbus — over a numeric or bit element; a Modbus String element takes any node type — AbCip,
/// AbLegacy, TwinCAT — TwinCAT keeps
/// DATE / DT and WSTRING under a DateTime node, and STRING / WSTRING under a String node; a DateTime's text
/// form is culture-dependent, so a STRING under a DateTime node is left out), and a <c>Boolean</c> node on
/// AbLegacy, whose encoder refuses bool elements. An unknown DataType name (served as BaseDataType) hands the
@@ -89,6 +100,30 @@ public static class DriverArrayWriteSupport
["Int64"] = 4, ["UInt64"] = 4, ["Float64"] = 4,
};
// The Modbus enums' member names (ModbusRegion / ModbusDataType / ModbusByteOrder / ModbusStringByteOrder in
// Driver.Modbus.Addressing, which this assembly cannot reference). The parity tests enumerate the real enums, so a
// member added there without being added here fails a test.
private static readonly HashSet<string> ModbusRegions = new(StringComparer.OrdinalIgnoreCase)
{
"Coils", "DiscreteInputs", "InputRegisters", "HoldingRegisters",
};
private static readonly HashSet<string> ModbusDataTypes = new(StringComparer.OrdinalIgnoreCase)
{
"Bool", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Float32", "Float64",
"BitInRegister", "String", "Bcd16", "Bcd32",
};
private static readonly HashSet<string> ModbusByteOrders = new(StringComparer.OrdinalIgnoreCase)
{
"BigEndian", "WordSwap", "ByteSwap", "FullReverse",
};
private static readonly HashSet<string> ModbusStringByteOrders = new(StringComparer.OrdinalIgnoreCase)
{
"HighByteFirst", "LowByteFirst",
};
private static readonly HashSet<string> S7ArrayTypes = new(StringComparer.OrdinalIgnoreCase)
{
"Byte", "Int16", "UInt16", "Int32", "UInt32", "Float32",
@@ -148,22 +183,7 @@ public static class DriverArrayWriteSupport
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: 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 count <= ModbusMaxCoilsPerWrite;
return string.Equals(region, "HoldingRegisters", StringComparison.OrdinalIgnoreCase)
&& ModbusRegisterArrayTypes.TryGetValue(native, out var registersPerElement)
&& (long)count * registersPerElement <= ModbusMaxRegistersPerWrite(driverConfigs);
}
return ModbusSupports(dataType, tagConfig, driverConfigs);
case DriverTypeNames.S7:
{
// S7TagDefinitionFactory requires a dataType key (absent ⇒ not an S7 tag at all); a present
@@ -185,6 +205,76 @@ public static class DriverArrayWriteSupport
}
}
// ModbusTagAddressResolution + ModbusTagDefinitionFactory + ModbusDriver.WriteAsync, KEYS shape only:
// - `region` AND `address` both present (Classify == Keys). Anything else is a string-only (`addressString`) blob the
// driver resolves with ITS family through ModbusAddressParser, which this assembly cannot reference — so it is
// never proven writable here and stays read-only (conservative: a hidden write, never an advertised refusal).
// - the strict key reads: `address` an integer 0–65535; a present string `region` / `dataType` / `byteOrder` /
// `stringByteOrder` must be a member name (a non-string token is read as absent ⇒ the default).
// - Coils: the resolver COERCES any type to Bool (never refuses); one FC15 of at most 1968 coils.
// - HoldingRegisters: never Bool; fixed-width numeric, String ((stringLength+1)/2 registers, stringLength >= 1) or
// BitInRegister (one register each, bitIndex 0–15, span ending at or before 65535); arrayLength × registers per
// element within maxRegistersPerWrite (the driver refuses rather than chunks; FC23 is opt-in and capped lower,
// falling back to FC16, so it never changes the answer).
// - Elements CONVERT, so the node type is not exact-matched; a String / DateTime node over a numeric or bit element
// never converts and is left out. A String element Convert.ToString()s any node type.
private static bool ModbusSupports(string? dataType, string? tagConfig, string?[]? driverConfigs)
{
if (!TryParseObject(tagConfig, out var root)
|| !root.TryGetProperty("region", out _)
|| !root.TryGetProperty("address", out var addressEl) || addressEl.ValueKind != JsonValueKind.Number
|| !addressEl.TryGetInt32(out var address) || address is < 0 or > ushort.MaxValue
|| ReadArrayLength(root) is not { } count
|| !TryReadModbusEnum(root, "region", ModbusRegions, out var region)
|| !TryReadModbusEnum(root, "dataType", ModbusDataTypes, out var native)
|| !TryReadModbusEnum(root, "byteOrder", ModbusByteOrders, out _)
|| !TryReadModbusEnum(root, "stringByteOrder", ModbusStringByteOrders, out _))
return false;
region ??= "HoldingRegisters";
if (region.Equals("Coils", StringComparison.OrdinalIgnoreCase))
return count <= ModbusMaxCoilsPerWrite && dataType is not ("String" or "DateTime");
if (!region.Equals("HoldingRegisters", StringComparison.OrdinalIgnoreCase)) return false; // input regions are read-only
native ??= "Int16";
int registersPerElement;
if (native.Equals("String", StringComparison.OrdinalIgnoreCase))
{
// The factory narrows to ushort ((ushort)ReadInt) and refuses a zero length.
var stringLength = (ushort)ReadInt(root, "stringLength");
if (stringLength < 1) return false;
registersPerElement = (stringLength + 1) / 2;
}
else
{
if (dataType is "String" or "DateTime") return false;
if (native.Equals("BitInRegister", StringComparison.OrdinalIgnoreCase))
{
// The factory narrows to byte ((byte)ReadInt) and refuses a bit above 15; the array RMW refuses a span
// past the last register.
if ((byte)ReadInt(root, "bitIndex") > 15 || (long)address + count - 1 > ushort.MaxValue) return false;
registersPerElement = 1;
}
else if (!ModbusRegisterArrayTypes.TryGetValue(native, out registersPerElement))
{
return false; // Bool in a register region: the factory refuses the pair
}
}
return (long)count * registersPerElement <= ModbusMaxRegistersPerWrite(driverConfigs);
}
// TagConfigJson.TryReadEnumStrict over the Modbus enum's member names: absent or a non-string token ⇒ null (the
// caller applies the default); a string must name a member (trimmed, case-insensitive), else the tag is refused.
// Numeric spellings are not member names, so they are refused exactly as ClassifyEnumText refuses them.
private static bool TryReadModbusEnum(JsonElement root, string name, HashSet<string> members, out string? value)
{
value = ReadEnumName(root, name)?.Trim();
return value is null || members.Contains(value);
}
private static int ReadInt(JsonElement root, string name) =>
root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var v) ? v : 0;
// AbCipTagDefinitionFactory + AbCipDriver.WriteAsync: a non-blank string tagPath, an atomic element type the codec
// has a width for (BOOL arrays are bit-packed DWORDs, STRING / structure elements are structures), a declared
// length, and not a safety tag (refused BadNotWritable). Each element is CONVERTED, so any numeric / Boolean node