fix(arrays): Modbus row honours the FC16/FC03 protocol ceilings, the RMW read cap and the address-space end
Review r0 of Integration-W3: - I1: the effective FC16 cap is min(configured maxRegistersPerWrite, 123). The driver trusts a larger configured cap and sends an over-ceiling FC16 (past 127 registers its byte-count byte wraps), which every conformant device refuses, so such an array is never advertised writable. - I2: a BitInRegister[] write reads its span first in one unchunked FC03, so it is also bounded by min(configured maxRegistersPerRead, 125) (absent/0 = 125). - m2: every span (registers and coils) must end at or before address 65535. - m1: the String-node wording says it converts only when the text parses, which the server cannot promise (DateTime never converts). - m4: the parity device now enforces FC01/02 2000, FC03/04 125 (or a smaller device read limit), FC15 1968, FC16 123, FC23 121/125, the FC15/FC16 byte-count field (exception 03) and the address-space end (exception 02). Tests: parity rows for each ceiling/cap/span boundary against the real write path, and Core.Abstractions row tests. The configured-cap test that asserted a cap of 200 lifts the ceiling now asserts the opposite. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -149,7 +149,8 @@ Two things to know before relying on any of this:
|
||||
a new sink wrapper must forward that argument, and the reflection guard checks only that the call arrives);
|
||||
`IndexRange` writes ⇒ `BadWriteNotSupported`, `ValueRank > 1` out of scope. An
|
||||
array node is writable only when the driver can write the array (`DriverArrayWriteSupport`: Modbus keys-addressed tags bounded
|
||||
by `maxRegistersPerWrite`/coil cap incl. String/BitInRegister arrays (#741; a string-only `addressString` tag
|
||||
by `maxRegistersPerWrite` (never above FC16's 123)/coil cap/address 65535 incl. String/BitInRegister arrays
|
||||
(BitInRegister also by `maxRegistersPerRead`: its RMW is one unchunked FC03) (#741; a string-only `addressString` tag
|
||||
stays read-only — family unknown at compose time), S7 non-Bool/non-wide types, OpcUaClient, Galaxy signed types **with a
|
||||
declared `arrayLength`** — an undeclared length stays read-only because a short whole-array write would
|
||||
resize the Galaxy attribute; AbCip atomic non-BOOL types, AbLegacy Int/Long/Float on the matching data file
|
||||
|
||||
+4
-4
@@ -323,7 +323,7 @@ carried on the plan records (`RawTagPlan.ArrayWritable`, mirrored onto every UNS
|
||||
|
||||
| Driver | Array writes | Element types |
|
||||
|---|---|---|
|
||||
| **Modbus** | Yes (String / BitInRegister arrays since #741) | Tags addressed by `region` + `address` keys (strict reads: an integer address 0–65535, enum strings that name a member). `region: Coils` (FC15, ≤ 1968 coils) with **any** `dataType` — a bit region is coerced to Bool, never refused. `region: HoldingRegisters` (FC16) with a fixed-width numeric type (`Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64`), `String` (`(stringLength+1)/2` registers per element, `stringLength >= 1`) or `BitInRegister` (one register per element, read-modify-written in one FC03 + one FC16, `bitIndex` 0–15, span ending at or before register 65535), whose `arrayLength × registers-per-element` fits `maxRegistersPerWrite` (default 123; the smallest value set on the driver or the tag's device wins) — the driver refuses rather than splits an over-cap FC16, so e.g. a 62-element `Int32` array (124 registers) stays read-only. FC23 (opt-in) never changes the answer. Not Bool-in-register; input regions are read-only. A **string-only** tag (`addressString`, no keys) stays read-only: the driver resolves it with its own family, which the composer cannot know — use the editor's Resolve to pin the keys |
|
||||
| **Modbus** | Yes (String / BitInRegister arrays since #741) | Tags addressed by `region` + `address` keys (strict reads: an integer address 0–65535, enum strings that name a member). `region: Coils` (FC15, ≤ 1968 coils) with **any** `dataType` — a bit region is coerced to Bool, never refused. `region: HoldingRegisters` (FC16) with a fixed-width numeric type (`Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64`), `String` (`(stringLength+1)/2` registers per element, `stringLength >= 1`) or `BitInRegister` (one register per element, read-modify-written in one FC03 + one FC16, `bitIndex` 0–15, and — because of that unchunked FC03 — also within `maxRegistersPerRead`, default and protocol ceiling 125), whose `arrayLength × registers-per-element` fits `maxRegistersPerWrite` (default 123; the smallest value set on the driver or the tag's device wins; a value above FC16's protocol ceiling of 123 never lifts it, since a conformant device refuses a longer FC16) — the driver refuses rather than splits an over-cap FC16, so e.g. a 62-element `Int32` array (124 registers) stays read-only. Every span (registers or coils) must end at or before address 65535. FC23 (opt-in) never changes the answer. Not Bool-in-register; input regions are read-only. A **string-only** tag (`addressString`, no keys) stays read-only: the driver resolves it with its own family, which the composer cannot know — use the editor's Resolve to pin the keys |
|
||||
| **S7** | Yes | `Byte`/`Int16`/`UInt16`/`Int32`/`UInt32`/`Float32` (one contiguous block). Not `Bool` (would clobber neighbouring bits) nor the wide / String / DateTime types |
|
||||
| **OpcUaClient** | Yes | All — forwarded upstream unchanged; the upstream server decides (shape included) |
|
||||
| **Galaxy** | Yes | OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime` (the `MxValueEncoder` array variants) — **only with a declared `arrayLength`**: an undeclared length materialises an unbounded node, and a short whole-array write would *resize* the Galaxy attribute (mxaccessgw contract), so such a node stays read-only |
|
||||
@@ -341,9 +341,9 @@ stays read-only.
|
||||
nodes: the node manager converts a numeric array to the node's element type, and the AB CIP, AB Legacy
|
||||
and TwinCAT encoders convert each element again to the controller's type (an element that does not fit
|
||||
is `BadOutOfRange`), as does Modbus to the register / coil type. Those rows therefore do not key on an
|
||||
exact node `DataType`; they leave out only node types whose elements never convert — a `DateTime` or
|
||||
`String` node over a numeric element (on Modbus, over a numeric or bit element; a Modbus `String`
|
||||
element takes any node type), and a `Boolean` node on AB Legacy. An array tag deployed read-only before its driver gained an encoder turns
|
||||
exact node `DataType`; they leave out only node types whose elements the server cannot promise to convert — a `DateTime` (never converts) or
|
||||
`String` node over a numeric element (converts only when the text happens to parse, `"1"` / `"true"`;
|
||||
on Modbus, over a numeric or bit element; a Modbus `String` element takes any node type), and a `Boolean` node on AB Legacy. An array tag deployed read-only before its driver gained an encoder turns
|
||||
writable on the next deploy (a surgical `ArrayWritable` update, no rebuild).
|
||||
|
||||
Each "Yes" row is pinned against the driver's real encoder by a parity test in that driver's
|
||||
|
||||
@@ -141,7 +141,9 @@ bring-up required; the sim is always-on on the shared Docker host).
|
||||
**Array writes** (#610, #741) — a whole-array write goes out as one FC15 (coils) or one FC16
|
||||
(holding registers: numeric, `String` at `(stringLength+1)/2` registers per element, and
|
||||
`BitInRegister` as one FC03 + one FC16 read-modify-write of the span); the driver refuses rather than
|
||||
splits an FC16 longer than `maxRegistersPerWrite` (default 123). The server mirrors that: an array tag
|
||||
splits an FC16 longer than `maxRegistersPerWrite` (default 123; a larger value never lifts FC16's
|
||||
protocol ceiling of 123, and a BitInRegister array's unchunked FC03 also bounds it by
|
||||
`maxRegistersPerRead`). The server mirrors that: an array tag
|
||||
is client-writable only when it is addressed by `region` + `address` keys and
|
||||
`arrayLength × registers-per-element` fits the cap (and a coil array ≤ 1968), else its node stays
|
||||
read-only. A string-only (`addressString`) array stays read-only, because the composer cannot resolve
|
||||
|
||||
@@ -301,16 +301,22 @@ only for what `ModbusDriver.WriteAsync` encodes end to end
|
||||
not chunk). Any `dataType` — a bit region is coerced to Bool.
|
||||
- **HoldingRegisters:** one FC16 whose register count fits
|
||||
`maxRegistersPerWrite` (default 123; the smallest value on the driver or the
|
||||
tag's device wins). Registers per element: 1 / 2 / 4 for the numeric types,
|
||||
tag's device wins), and never more than FC16's protocol ceiling of 123 — a
|
||||
larger configured cap does not lift it, because a conformant device refuses
|
||||
the longer FC16. Registers per element: 1 / 2 / 4 for the numeric types,
|
||||
`(stringLength+1)/2` for `String` (each element nul-padded to that width), 1
|
||||
for `BitInRegister` (the span is read with one FC03, the addressed bit of each
|
||||
register is set or cleared, and the span is written back with one FC16 —
|
||||
the other 15 bits are preserved; `bitIndex` 0–15, the span must end at or
|
||||
before register 65535). FC23 (`useFC23ForMultiRegisterWrites`) is opt-in,
|
||||
the other 15 bits are preserved; `bitIndex` 0–15). Because that FC03 is one
|
||||
unchunked read of the whole span, a BitInRegister array must also fit
|
||||
`maxRegistersPerRead` (default and protocol ceiling 125).
|
||||
- **Every span** — registers or coils — must end at or before address 65535
|
||||
(a conformant device answers exception 02 past it). FC23 (`useFC23ForMultiRegisterWrites`) is opt-in,
|
||||
capped at 121 registers and falls back to FC16 above that, so it never
|
||||
changes which arrays are writable.
|
||||
- **Never writable:** DiscreteInputs / InputRegisters, Bool in a register
|
||||
region, a `String` with no `stringLength`, a `bitIndex` above 15, a missing
|
||||
or zero `arrayLength`, and a `String` / `DateTime` node over a numeric or bit
|
||||
element (such an element never converts). Elements otherwise convert, so a
|
||||
element (a DateTime never converts; a String converts only when its text
|
||||
happens to parse, which the server cannot promise). Elements otherwise convert, so a
|
||||
ScadaBridge `int[]` to an `Int16` node is writable.
|
||||
|
||||
@@ -27,10 +27,14 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// 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 >= 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
|
||||
/// <c>BitInRegister</c> (one register per element; <c>bitIndex</c> 0–15), 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) and never more than FC16's protocol ceiling of 123,
|
||||
/// however high the cap is configured (a conformant device refuses a longer FC16, so that write always
|
||||
/// fails). A BitInRegister array is read-modify-written — one unchunked FC03 of the span, then one FC16 —
|
||||
/// so it is also bounded by <c>maxRegistersPerRead</c> (default and protocol ceiling 125). Every span
|
||||
/// (registers or coils) ends at or before address 65535. 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>
|
||||
@@ -72,9 +76,10 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// 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>); 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
|
||||
/// out only the node types whose elements the server cannot promise to convert: a <c>DateTime</c> node over a
|
||||
/// numeric element (it never converts) or a <c>String</c> node over one (it converts only when the text happens
|
||||
/// to parse, <c>"1"</c> / <c>"true"</c>) — on Modbus over a numeric or bit element, and a Modbus String element
|
||||
/// takes any node type; likewise on AbCip, AbLegacy and 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
|
||||
@@ -85,13 +90,26 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
public static class DriverArrayWriteSupport
|
||||
{
|
||||
/// <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>
|
||||
/// (<c>ModbusDriver</c> resolves it the same way). A configured value only ever LOWERS it — see
|
||||
/// <see cref="ModbusProtocolMaxRegistersPerWrite"/>.</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;
|
||||
|
||||
/// <summary>Modbus FC16's protocol ceiling on registers per request (0x7B). A configured
|
||||
/// <c>maxRegistersPerWrite</c> above it never lifts it: a conformant device refuses a longer FC16.</summary>
|
||||
public const int ModbusProtocolMaxRegistersPerWrite = 123;
|
||||
|
||||
/// <summary>Modbus FC03's protocol ceiling on registers per request (0x7D) — the bound on a BitInRegister[]
|
||||
/// write's read-modify-write read, together with a configured <c>maxRegistersPerRead</c>.</summary>
|
||||
public const int ModbusProtocolMaxRegistersPerRead = 125;
|
||||
|
||||
/// <summary>The last Modbus address (registers and coils alike): a span ending past it is refused by every
|
||||
/// conformant device (exception 02).</summary>
|
||||
private const long ModbusLastAddress = ushort.MaxValue;
|
||||
|
||||
// Element type → registers per element (ModbusDriver.RegisterCount).
|
||||
private static readonly Dictionary<string, int> ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -213,11 +231,14 @@ public static class DriverArrayWriteSupport
|
||||
// `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.
|
||||
// BitInRegister (one register each, bitIndex 0–15); arrayLength × registers per element within
|
||||
// maxRegistersPerWrite clamped to FC16's 123 (the driver refuses rather than chunks; FC23 is opt-in and capped
|
||||
// lower, falling back to FC16, so it never changes the answer). BitInRegister[] also READS its span first in one
|
||||
// unchunked FC03, so it is bounded by maxRegistersPerRead (clamped to FC03's 125) too.
|
||||
// - Every span ends at or before address 65535 (a conformant device refuses one past it with exception 02).
|
||||
// - Elements CONVERT, so the node type is not exact-matched. A DateTime node over a numeric or bit element never
|
||||
// converts; a String node over one converts only when the text happens to parse ("1", "true"), which the server
|
||||
// cannot promise — both are 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)
|
||||
@@ -233,7 +254,8 @@ public static class DriverArrayWriteSupport
|
||||
region ??= "HoldingRegisters";
|
||||
|
||||
if (region.Equals("Coils", StringComparison.OrdinalIgnoreCase))
|
||||
return count <= ModbusMaxCoilsPerWrite && dataType is not ("String" or "DateTime");
|
||||
return count <= ModbusMaxCoilsPerWrite && address + (long)count - 1 <= ModbusLastAddress
|
||||
&& dataType is not ("String" or "DateTime");
|
||||
if (!region.Equals("HoldingRegisters", StringComparison.OrdinalIgnoreCase)) return false; // input regions are read-only
|
||||
|
||||
native ??= "Int16";
|
||||
@@ -250,9 +272,9 @@ public static class DriverArrayWriteSupport
|
||||
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;
|
||||
// The factory narrows to byte ((byte)ReadInt) and refuses a bit above 15. The RMW reads the whole span
|
||||
// in ONE unchunked FC03 before writing it, so the read cap bounds it too.
|
||||
if ((byte)ReadInt(root, "bitIndex") > 15 || count > ModbusMaxRegistersPerRead(driverConfigs)) return false;
|
||||
registersPerElement = 1;
|
||||
}
|
||||
else if (!ModbusRegisterArrayTypes.TryGetValue(native, out registersPerElement))
|
||||
@@ -260,7 +282,8 @@ public static class DriverArrayWriteSupport
|
||||
return false; // Bool in a register region: the factory refuses the pair
|
||||
}
|
||||
}
|
||||
return (long)count * registersPerElement <= ModbusMaxRegistersPerWrite(driverConfigs);
|
||||
var registers = (long)count * registersPerElement;
|
||||
return registers <= ModbusMaxRegistersPerWrite(driverConfigs) && address + registers - 1 <= ModbusLastAddress;
|
||||
}
|
||||
|
||||
// TagConfigJson.TryReadEnumStrict over the Modbus enum's member names: absent or a non-string token ⇒ null (the
|
||||
@@ -428,9 +451,24 @@ public static class DriverArrayWriteSupport
|
||||
public static bool RefusesArrayValueOnScalarTag(string? driverType) =>
|
||||
driverType is DriverTypeNames.Modbus or DriverTypeNames.S7;
|
||||
|
||||
// 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)
|
||||
// The effective Modbus FC16 write cap: the smallest maxRegistersPerWrite configured in any config (absent /
|
||||
// non-positive ⇒ the driver default, 123), CLAMPED to FC16's protocol ceiling of 123. The driver trusts a larger
|
||||
// configured cap and sends the over-ceiling FC16 (past 127 registers its one-byte byte-count field even wraps), which
|
||||
// every conformant device refuses — so such a write always fails and is never advertised.
|
||||
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs) =>
|
||||
Math.Min(SmallestPositive(driverConfigs, "maxRegistersPerWrite") ?? ModbusDefaultMaxRegistersPerWrite,
|
||||
ModbusProtocolMaxRegistersPerWrite);
|
||||
|
||||
// The effective Modbus FC03 read cap: the smallest maxRegistersPerRead configured (absent / non-positive ⇒ the driver
|
||||
// default, 125), clamped to FC03's protocol ceiling of 125. Only the BitInRegister[] write reads (its RMW issues one
|
||||
// unchunked FC03 of the whole span), so only that shape is bounded by it.
|
||||
private static int ModbusMaxRegistersPerRead(string?[]? driverConfigs) =>
|
||||
Math.Min(SmallestPositive(driverConfigs, "maxRegistersPerRead") ?? ModbusProtocolMaxRegistersPerRead,
|
||||
ModbusProtocolMaxRegistersPerRead);
|
||||
|
||||
// The smallest positive Int32 value of `key` (matched case-insensitively, like the driver's DTO binding) at the root
|
||||
// of any config; null when none carries one.
|
||||
private static int? SmallestPositive(string?[]? driverConfigs, string key)
|
||||
{
|
||||
int? cap = null;
|
||||
foreach (var config in driverConfigs ?? [])
|
||||
@@ -438,7 +476,7 @@ public static class DriverArrayWriteSupport
|
||||
if (!TryParseObject(config, out var root)) continue;
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, "maxRegistersPerWrite", StringComparison.OrdinalIgnoreCase)
|
||||
if (string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)
|
||||
&& prop.Value.ValueKind == JsonValueKind.Number
|
||||
&& prop.Value.TryGetInt32(out var configured) && configured > 0)
|
||||
{
|
||||
@@ -446,7 +484,7 @@ public static class DriverArrayWriteSupport
|
||||
}
|
||||
}
|
||||
}
|
||||
return cap ?? ModbusDefaultMaxRegistersPerWrite;
|
||||
return cap;
|
||||
}
|
||||
|
||||
// The declared element count: isArray is the JSON literal true AND arrayLength is an Int32 >= 1 — the rule the
|
||||
|
||||
+43
-3
@@ -74,11 +74,50 @@ public sealed class DriverArrayWriteSupportTests
|
||||
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}")
|
||||
// A configured cap above the FC16 protocol ceiling (123) never lifts it: a conformant device refuses 124
|
||||
// registers (and past 127 the driver's one-byte byte-count field wraps), so the write always fails.
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 123), "{\"maxRegistersPerWrite\":200}")
|
||||
.ShouldBeTrue();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 124), "{\"maxRegistersPerWrite\":200}")
|
||||
.ShouldBeFalse();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("BitInRegister", 124), "{\"maxRegistersPerWrite\":200}")
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A BitInRegister[] write is a read-modify-write: one unchunked FC03 of the whole span first, so its
|
||||
/// length is also bounded by <c>maxRegistersPerRead</c> (smallest positive value across the configs; absent / 0 =>
|
||||
/// 125, and never above FC03's protocol ceiling of 125). The other register shapes never read on write.</summary>
|
||||
[Fact]
|
||||
public void Modbus_bit_in_register_arrays_respect_the_read_cap()
|
||||
{
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 64), "{\"maxRegistersPerRead\":64}").ShouldBeTrue();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 65), "{\"maxRegistersPerRead\":64}").ShouldBeFalse();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 65), "{\"maxRegistersPerRead\":100}", "{\"MaxRegistersPerRead\":64}")
|
||||
.ShouldBeFalse();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 123), "{\"maxRegistersPerRead\":0}").ShouldBeTrue();
|
||||
// A numeric array of the same length is not read on write, so the read cap does not apply to it.
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 65), "{\"maxRegistersPerRead\":64}").ShouldBeTrue();
|
||||
// With the write cap lifted, the read cap is the bound.
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 100),
|
||||
"{\"maxRegistersPerRead\":100,\"maxRegistersPerWrite\":200}").ShouldBeTrue();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 101),
|
||||
"{\"maxRegistersPerRead\":100,\"maxRegistersPerWrite\":200}").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Every shape's span must end at or before address 65535 - a conformant device refuses a write past it
|
||||
/// (exception 02).</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65530,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":6}", true)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65530,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":7}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65532,\"dataType\":\"Int32\",\"isArray\":true,\"arrayLength\":2}", true)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65533,\"dataType\":\"Int32\",\"isArray\":true,\"arrayLength\":2}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65534,\"dataType\":\"String\",\"stringLength\":4,\"isArray\":true,\"arrayLength\":1}", true)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65535,\"dataType\":\"String\",\"stringLength\":4,\"isArray\":true,\"arrayLength\":1}", false)]
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":65000,\"isArray\":true,\"arrayLength\":536}", true)]
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":65000,\"isArray\":true,\"arrayLength\":537}", false)]
|
||||
public void Modbus_array_spans_end_within_the_address_space(string tagConfig, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>FC15 carries at most 1968 coils and the driver sends one request, so a longer coil array is not
|
||||
/// writable.</summary>
|
||||
[Fact]
|
||||
@@ -104,7 +143,8 @@ public sealed class DriverArrayWriteSupportTests
|
||||
$"{{\"region\":\"HoldingRegisters\",\"address\":0,{typeKeys},\"isArray\":true,\"arrayLength\":{length}}}").ShouldBe(expected);
|
||||
|
||||
/// <summary>Elements CONVERT (ScadaBridge's <c>int[]</c> to an Int16 node), so the Modbus row keys on no exact node
|
||||
/// DataType; it leaves out only a String or DateTime node over a numeric / bit element, which never converts. A
|
||||
/// DataType; it leaves out only a String or DateTime node over a numeric / bit element (a DateTime never converts; a String converts only when its text happens to
|
||||
/// parse, which the server cannot promise). A
|
||||
/// String element takes any node type (each element is <c>Convert.ToString</c>'d).</summary>
|
||||
[Theory]
|
||||
[InlineData("Int16", "Int16", true)]
|
||||
|
||||
+89
-18
@@ -13,11 +13,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
/// refuses (nor hide one it accepts).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// "Accepted" means Good status AND a write PDU (FC15 / FC16 / FC23) reached the transport. The transport is a
|
||||
/// spec-conformant device: it holds the whole 0–65535 address space and answers an FC15 of more than 1968 coils
|
||||
/// with exception 03, as every conformant device does — the driver itself sends one FC15 without chunking, so the
|
||||
/// table's 1968 ceiling is the device's refusal, not the driver's. The FC16 cap is the driver's own
|
||||
/// (<c>MaxRegistersPerWrite</c>). FC23 is opt-in and never changes the answer (pinned below).
|
||||
/// "Accepted" means Good status AND a write PDU (FC15 / FC16 / FC23) the device took. The transport is a
|
||||
/// spec-conformant device: it holds the whole 0–65535 address space and refuses what every conformant device
|
||||
/// refuses — a quantity over a protocol ceiling (FC15 1968 coils, FC16 123 and FC03 125 registers) or a span past
|
||||
/// address 65535. The driver sends one FC15 / FC16 (and, for BitInRegister[], one FC03) without chunking, so those
|
||||
/// ceilings are the device's refusal; a configured <c>MaxRegistersPerWrite</c> below 123 is the driver's own. FC23
|
||||
/// is opt-in and never changes the answer (pinned below).
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusArrayWriteSupportParityTests
|
||||
@@ -46,14 +47,17 @@ public sealed class ModbusArrayWriteSupportParityTests
|
||||
|
||||
/// <summary>Drives the real driver: true iff the write is Good AND a write PDU reached the device.</summary>
|
||||
private static async Task<(bool Accepted, uint Status)> DriverEncodesAsync(
|
||||
string tagConfig, object value, ushort maxRegistersPerWrite = 123, bool fc23 = false)
|
||||
string tagConfig, object value, ushort maxRegistersPerWrite = 123, bool fc23 = false, ushort maxRegistersPerRead = 125)
|
||||
{
|
||||
var device = new ConformantDevice();
|
||||
// The operator configures maxRegistersPerRead because the DEVICE reads no more than that per FC03, so the
|
||||
// modelled device refuses a longer read (never above FC03's protocol ceiling of 125).
|
||||
var device = new ConformantDevice { MaxReadRegisters = Math.Min(maxRegistersPerRead == 0 ? 125 : (int)maxRegistersPerRead, 125) };
|
||||
var drv = new ModbusDriver(
|
||||
new ModbusDriverOptions
|
||||
{
|
||||
Host = "fake", RawTags = [new RawTagEntry(Ref, tagConfig, false)],
|
||||
MaxRegistersPerWrite = maxRegistersPerWrite, UseFC23ForMultiRegisterWrites = fc23,
|
||||
MaxRegistersPerRead = maxRegistersPerRead,
|
||||
},
|
||||
"modbus-array-parity", _ => device);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
@@ -181,6 +185,55 @@ public sealed class ModbusArrayWriteSupportParityTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The protocol ceilings and the configured caps together, against the real write path:
|
||||
/// <list type="bullet">
|
||||
/// <item>a <c>maxRegistersPerWrite</c> above FC16's 123 never lifts it - the driver sends the over-ceiling
|
||||
/// FC16 and a conformant device refuses it (past 127 the byte-count field even wraps);</item>
|
||||
/// <item>a BitInRegister[] write reads its span first in ONE unchunked FC03, so it is also bounded by
|
||||
/// <c>maxRegistersPerRead</c> (and FC03's 125); numeric arrays never read on write;</item>
|
||||
/// <item>every span ends at or before address 65535 (exception 02 past it).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Theory]
|
||||
// Write cap above the FC16 ceiling.
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":123}""", 123, 200, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":124}""", 124, 200, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":150}""", 150, 200, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":62}""", 62, 200, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":123}""", 123, 200, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":124}""", 124, 200, 200)]
|
||||
// BitInRegister[] RMW read bounded by maxRegistersPerRead.
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":64}""", 64, 0, 64)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":65}""", 65, 0, 64)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":100}""", 100, 0, 64)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":100}""", 100, 0, 64)] // no read on write
|
||||
// Span end at 65535.
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"Int16","isArray":true,"arrayLength":6}""", 6, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"Int16","isArray":true,"arrayLength":7}""", 7, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65532,"dataType":"Int32","isArray":true,"arrayLength":2}""", 2, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65533,"dataType":"Int32","isArray":true,"arrayLength":2}""", 2, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65534,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":1}""", 1, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65535,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":1}""", 1, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"BitInRegister","isArray":true,"arrayLength":6}""", 6, 0, 0)]
|
||||
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"BitInRegister","isArray":true,"arrayLength":7}""", 7, 0, 0)]
|
||||
[InlineData("""{"region":"Coils","address":65000,"isArray":true,"arrayLength":536}""", 536, 0, 0)]
|
||||
[InlineData("""{"region":"Coils","address":65000,"isArray":true,"arrayLength":537}""", 537, 0, 0)]
|
||||
public async Task Table_agrees_with_the_driver_on_protocol_ceilings_and_spans(
|
||||
string tagConfig, int elements, ushort maxRegistersPerWrite, ushort maxRegistersPerRead)
|
||||
{
|
||||
var configs = new List<string>();
|
||||
if (maxRegistersPerWrite != 0) configs.Add($$"""{"maxRegistersPerWrite":{{maxRegistersPerWrite}}}""");
|
||||
if (maxRegistersPerRead != 0) configs.Add($$"""{"maxRegistersPerRead":{{maxRegistersPerRead}}}""");
|
||||
|
||||
var (accepted, status) = await DriverEncodesAsync(tagConfig, ClrArray("Int16", elements),
|
||||
maxRegistersPerWrite == 0 ? (ushort)123 : maxRegistersPerWrite,
|
||||
maxRegistersPerRead: maxRegistersPerRead == 0 ? (ushort)125 : maxRegistersPerRead);
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig, configs.ToArray()).ShouldBe(
|
||||
accepted, $"{tagConfig} write={maxRegistersPerWrite} read={maxRegistersPerRead}: driver status 0x{status:X8}");
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the edges: each cap boundary has a writable side and a refused side in the table.</summary>
|
||||
[Fact]
|
||||
public void The_shape_edges_contain_both_answers()
|
||||
@@ -286,8 +339,10 @@ public sealed class ModbusArrayWriteSupportParityTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A spec-conformant Modbus device over the whole 0–65535 address space. It refuses an FC15 of more than
|
||||
/// 1968 coils with exception 03 (Illegal Data Value), as the spec requires; everything else it answers.
|
||||
/// A spec-conformant Modbus device over the whole 0-65535 address space. It enforces the protocol quantity
|
||||
/// ceilings (FC01/02 <= 2000, FC03/04 <= 125, FC15 <= 1968, FC16 <= 123, FC23 write <= 121 / read <= 125) and the
|
||||
/// FC15 / FC16 byte-count field with exception 03 (Illegal Data Value), and a span past address 65535 with
|
||||
/// exception 02 (Illegal Data Address) - what a real device answers. Everything else it answers.
|
||||
/// </summary>
|
||||
private sealed class ConformantDevice : IModbusTransport
|
||||
{
|
||||
@@ -295,25 +350,38 @@ public sealed class ModbusArrayWriteSupportParityTests
|
||||
public readonly bool[] Coils = new bool[65536];
|
||||
public int WritePdus;
|
||||
|
||||
/// <summary>The device's FC03/FC04 quantity limit: the protocol's 125, or a smaller device-specific one.</summary>
|
||||
public int MaxReadRegisters { get; init; } = 125;
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
private static Task<byte[]> Refuse(byte fc, byte code) =>
|
||||
Task.FromException<byte[]>(new ModbusException(fc, code, $"fc={fc:X2} exception {code:X2}"));
|
||||
|
||||
// Exception 03 for a quantity outside 1..max, 02 for a span past the last address, else null (accepted).
|
||||
private static byte? Check(int addr, int qty, int max) =>
|
||||
qty < 1 || qty > max ? (byte)0x03 : addr + qty > 65536 ? (byte)0x02 : null;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var fc = pdu[0];
|
||||
var addr = (pdu[1] << 8) | pdu[2];
|
||||
var qty = (pdu[3] << 8) | pdu[4];
|
||||
switch (pdu[0])
|
||||
switch (fc)
|
||||
{
|
||||
case 0x01:
|
||||
case 0x02:
|
||||
{
|
||||
if (Check(addr, qty, 2000) is { } e) return Refuse(fc, e);
|
||||
var resp = new byte[2 + (qty + 7) / 8];
|
||||
resp[0] = pdu[0]; resp[1] = (byte)((qty + 7) / 8);
|
||||
resp[0] = fc; resp[1] = (byte)((qty + 7) / 8);
|
||||
for (var i = 0; i < qty; i++) if (Coils[addr + i]) resp[2 + i / 8] |= (byte)(1 << (i % 8));
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
case 0x03:
|
||||
case 0x04:
|
||||
return Task.FromResult(ReadRegs(pdu[0], addr, qty));
|
||||
if (Check(addr, qty, MaxReadRegisters) is { } re) return Refuse(fc, re);
|
||||
return Task.FromResult(ReadRegs(fc, addr, qty));
|
||||
case 0x05:
|
||||
WritePdus++;
|
||||
Coils[addr] = pdu[3] == 0xFF;
|
||||
@@ -323,26 +391,29 @@ public sealed class ModbusArrayWriteSupportParityTests
|
||||
Holding[addr] = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
return Task.FromResult(pdu);
|
||||
case 0x0F:
|
||||
if (qty is < 1 or > 1968)
|
||||
return Task.FromException<byte[]>(new ModbusException(0x0F, 0x03, "FC15 quantity outside 1..1968"));
|
||||
if (Check(addr, qty, 1968) is { } ce) return Refuse(fc, ce);
|
||||
if (pdu[5] != (qty + 7) / 8 || pdu.Length < 6 + pdu[5]) return Refuse(fc, 0x03);
|
||||
WritePdus++;
|
||||
for (var i = 0; i < qty; i++) Coils[addr + i] = ((pdu[6 + i / 8] >> (i % 8)) & 1) == 1;
|
||||
return Task.FromResult(new byte[] { 0x0F, pdu[1], pdu[2], pdu[3], pdu[4] });
|
||||
case 0x10:
|
||||
if (Check(addr, qty, 123) is { } we) return Refuse(fc, we);
|
||||
if (pdu[5] != qty * 2 || pdu.Length < 6 + pdu[5]) return Refuse(fc, 0x03);
|
||||
WritePdus++;
|
||||
for (var i = 0; i < qty; i++) Holding[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
|
||||
return Task.FromResult(new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] });
|
||||
case 0x17:
|
||||
{
|
||||
WritePdus++;
|
||||
var writeAddr = (pdu[5] << 8) | pdu[6];
|
||||
var writeQty = (pdu[7] << 8) | pdu[8];
|
||||
if (Check(addr, qty, 125) is { } r23) return Refuse(fc, r23);
|
||||
if (Check(writeAddr, writeQty, 121) is { } w23) return Refuse(fc, w23);
|
||||
WritePdus++;
|
||||
for (var i = 0; i < writeQty; i++) Holding[writeAddr + i] = (ushort)((pdu[10 + i * 2] << 8) | pdu[11 + i * 2]);
|
||||
var resp = ReadRegs(0x17, addr, qty);
|
||||
return Task.FromResult(resp);
|
||||
return Task.FromResult(ReadRegs(0x17, addr, qty));
|
||||
}
|
||||
default:
|
||||
return Task.FromException<byte[]>(new ModbusException(pdu[0], 0x01, "unsupported"));
|
||||
return Refuse(fc, 0x01);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user