Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusArrayWriteSupportParityTests.cs
T
Joseph Doherty cf62007678 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
2026-10-22 17:32:57 -04:00

435 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Gitea #610 / #741 — pins <see cref="DriverArrayWriteSupport"/>'s Modbus row against the REAL write path. For
/// every region × element type × node DataType, and for every shape edge (the FC16 / FC15 caps, the declared
/// length, the type-dependent guards, the address keys), the server advertises an array node writable exactly when
/// <see cref="ModbusDriver.WriteAsync"/> encodes the whole array onto the wire. A driver change that gains or loses
/// an array encoder fails here until the table follows — so the server can never advertise a write this driver
/// refuses (nor hide one it accepts).
/// </summary>
/// <remarks>
/// "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
{
private const string Ref = "cell/modbus/arr";
/// <summary>The CLR array the node manager hands the driver for a node of this DataType (it converts a numeric
/// client array to the node's element type first).</summary>
private static Array ClrArray(string nodeType, int n) => nodeType switch
{
"Boolean" => Enumerable.Repeat(true, n).ToArray(),
"Int16" => Enumerable.Repeat((short)1, n).ToArray(),
"UInt16" => Enumerable.Repeat((ushort)1, n).ToArray(),
"Int32" => Enumerable.Repeat(1, n).ToArray(),
"Int64" => Enumerable.Repeat(1L, n).ToArray(),
"Float" => Enumerable.Repeat(1f, n).ToArray(),
"Double" => Enumerable.Repeat(1d, n).ToArray(),
// Non-numeric text: a String node over a numeric / bit element converts only when the text happens to parse,
// which is not a write the server can promise — the table keeps it read-only.
"String" => Enumerable.Repeat("a", n).ToArray(),
"DateTime" => Enumerable.Repeat(DateTime.UtcNow, n).ToArray(),
_ => throw new ArgumentOutOfRangeException(nameof(nodeType)),
};
private static readonly string[] NodeTypes = ["Boolean", "Int16", "UInt16", "Int32", "Int64", "Float", "Double", "String", "DateTime"];
/// <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, ushort maxRegistersPerRead = 125)
{
// 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);
var result = await drv.WriteAsync([new WriteRequest(Ref, value)], CancellationToken.None);
var status = result.Single().StatusCode;
return (status == 0u && device.WritePdus > 0, status);
}
public static TheoryData<ModbusRegion, ModbusDataType, string> TypeMatrix()
{
var data = new TheoryData<ModbusRegion, ModbusDataType, string>();
foreach (var region in Enum.GetValues<ModbusRegion>())
foreach (var dt in Enum.GetValues<ModbusDataType>())
foreach (var node in NodeTypes)
data.Add(region, dt, node);
return data;
}
/// <summary>Every region × element type × node DataType: the table answers exactly what the write path does —
/// including String[] and BitInRegister[] holding-register arrays (#741 U3) and Coils authored with a non-Bool type,
/// which the resolver COERCES to Bool (never refuses).</summary>
[Theory]
[MemberData(nameof(TypeMatrix))]
public async Task Table_agrees_with_the_driver_write_path(ModbusRegion region, ModbusDataType dataType, string nodeType)
{
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"bitIndex":3,"isArray":true,"arrayLength":2}""";
var (accepted, status) = await DriverEncodesAsync(json, ClrArray(nodeType, 2));
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, nodeType, json).ShouldBe(
accepted, $"region={region} dataType={dataType} node={nodeType}: driver status 0x{status:X8}");
}
/// <summary>Positive control: the matrix above is not vacuously one answer, and it contains the shapes #741 added.</summary>
[Fact]
public void The_matrix_contains_both_answers()
{
var answers = Enum.GetValues<ModbusRegion>()
.SelectMany(r => Enum.GetValues<ModbusDataType>().Select(d => DriverArrayWriteSupport.Supports(
DriverTypeNames.Modbus, "Int16",
$$"""{"region":"{{r}}","address":10,"dataType":"{{d}}","stringLength":4,"isArray":true,"arrayLength":2}""")))
.ToHashSet();
answers.ShouldBe(new[] { true, false }, ignoreOrder: true);
}
/// <summary>
/// The shape edges, both directions, against the real write path: the FC16 cap for numeric, String and
/// BitInRegister arrays (default 123, or a configured <c>MaxRegistersPerWrite</c>), the FC15 ceiling, a
/// missing / zero <c>arrayLength</c> (a scalar to the factory, whose scalar encoder refuses an array value),
/// the String length and bit-index guards, the BitInRegister span end, and the strict key reads.
/// </summary>
[Theory]
// Numeric FC16 cap.
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":61}""", 61, 0)] // 122 regs
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":62}""", 62, 0)] // 124 regs
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":123}""", 123, 0)] // exactly 123
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":124}""", 124, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Float64","isArray":true,"arrayLength":30}""", 30, 0)] // 120 regs
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Float64","isArray":true,"arrayLength":31}""", 31, 0)] // 124 regs
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":10}""", 10, 10)] // exactly a configured cap
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":11}""", 11, 10)] // one over it
// String FC16 cap: (stringLength+1)/2 registers per element.
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":61}""", 61, 0)] // 122 regs
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"isArray":true,"arrayLength":62}""", 62, 0)] // 124 regs
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":5,"isArray":true,"arrayLength":41}""", 41, 0)] // 3 regs each: 123
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":5,"isArray":true,"arrayLength":42}""", 42, 0)] // 126
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":1,"isArray":true,"arrayLength":10}""", 10, 10)] // 1 reg each, at a configured cap
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":1,"isArray":true,"arrayLength":11}""", 11, 10)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":0,"isArray":true,"arrayLength":2}""", 2, 0)] // factory refuses
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","isArray":true,"arrayLength":2}""", 2, 0)] // no length ⇒ refused
// BitInRegister FC16 cap: one register per element, plus the bit-index guard and the span end.
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","bitIndex":15,"isArray":true,"arrayLength":123}""", 123, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","bitIndex":15,"isArray":true,"arrayLength":124}""", 124, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":10}""", 10, 10)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":11}""", 11, 10)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","bitIndex":16,"isArray":true,"arrayLength":2}""", 2, 0)] // factory refuses
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"BitInRegister","isArray":true,"arrayLength":6}""", 6, 0)] // ends at 65535
[InlineData("""{"region":"HoldingRegisters","address":65530,"dataType":"BitInRegister","isArray":true,"arrayLength":7}""", 7, 0)] // past the end
// FC15 ceiling (one request, no chunking; a conformant device refuses more than 1968 coils).
[InlineData("""{"region":"Coils","address":0,"isArray":true,"arrayLength":1968}""", 1968, 0)]
[InlineData("""{"region":"Coils","address":0,"isArray":true,"arrayLength":1969}""", 1969, 0)]
[InlineData("""{"region":"Coils","address":0,"dataType":"Float64","isArray":true,"arrayLength":1968}""", 1968, 0)] // coerced to Bool
[InlineData("""{"region":"Coils","address":0,"dataType":"Float64","isArray":true,"arrayLength":1969}""", 1969, 0)]
// Declared length.
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":0}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":1}""", 1, 0)]
// Strict key reads: a present-but-invalid enum or an out-of-range address refuses the tag.
[InlineData("""{"region":"Coils","address":0,"dataType":"Int17","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","byteOrder":"Sideways","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"stringByteOrder":"Middle","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Int16","byteOrder":"WordSwap","isArray":true,"arrayLength":2}""", 2, 0)] // valid order: positive control
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":4,"stringByteOrder":"LowByteFirst","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":65536,"dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":-1,"dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":1.5,"dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":"10","dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
[InlineData("""{"region":"HoldingRegisters","address":10,"dataType":"Bool","isArray":true,"arrayLength":2}""", 2, 0)] // register region never carries Bool
[InlineData("""{"region":0,"address":10,"dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)] // non-string region ⇒ absent ⇒ HoldingRegisters
// Keys win over an addressString beside them (the driver never reads the string then).
[InlineData("""{"region":"HoldingRegisters","address":10,"addressString":"30011","dataType":"Int16","isArray":true,"arrayLength":2}""", 2, 0)]
public async Task Table_agrees_with_the_driver_on_each_shape_edge(string tagConfig, int elements, ushort maxRegistersPerWrite)
{
var driverConfig = maxRegistersPerWrite == 0 ? "{}" : $$"""{"maxRegistersPerWrite":{{maxRegistersPerWrite}}}""";
var cap = maxRegistersPerWrite == 0 ? (ushort)123 : maxRegistersPerWrite;
var (accepted, status) = await DriverEncodesAsync(tagConfig, ClrArray("Int16", elements), cap);
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig, driverConfig).ShouldBe(
accepted, $"{tagConfig} cap={cap}: driver status 0x{status:X8}");
}
/// <summary>The table restates the Modbus enums' member names (Core.Abstractions cannot reference them); every
/// byte order and string byte order the driver accepts must be one it accepts too, so a new member fails here.</summary>
[Fact]
public async Task Every_byte_order_member_agrees_with_the_driver()
{
foreach (var order in Enum.GetValues<ModbusByteOrder>())
foreach (var stringOrder in Enum.GetValues<ModbusStringByteOrder>())
{
var json = $$"""{"region":"HoldingRegisters","address":10,"dataType":"Int32","byteOrder":"{{order}}","stringByteOrder":"{{stringOrder}}","isArray":true,"arrayLength":2}""";
var (accepted, _) = await DriverEncodesAsync(json, ClrArray("Int16", 2));
accepted.ShouldBeTrue(json);
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32", json).ShouldBeTrue(json);
}
}
/// <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()
{
static bool S(string json) => DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json);
S("""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":61}""").ShouldBeTrue();
S("""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":62}""").ShouldBeFalse();
S("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":5,"isArray":true,"arrayLength":41}""").ShouldBeTrue();
S("""{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":5,"isArray":true,"arrayLength":42}""").ShouldBeFalse();
S("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":123}""").ShouldBeTrue();
S("""{"region":"HoldingRegisters","address":10,"dataType":"BitInRegister","isArray":true,"arrayLength":124}""").ShouldBeFalse();
S("""{"region":"Coils","address":0,"isArray":true,"arrayLength":1968}""").ShouldBeTrue();
S("""{"region":"Coils","address":0,"isArray":true,"arrayLength":1969}""").ShouldBeFalse();
}
/// <summary>
/// FC23 is opt-in and never changes writability: with <c>UseFC23ForMultiRegisterWrites</c> on, the driver
/// writes a multi-register array via FC23 up to its 121-register ceiling and falls back to FC16 above it, so
/// the accept boundary is still the FC16 cap the table uses.
/// </summary>
[Theory]
[InlineData("Int16", 121)]
[InlineData("Int16", 123)]
[InlineData("Int16", 124)]
[InlineData("String", 61)]
[InlineData("String", 62)]
public async Task FC23_does_not_change_the_answer(string dataType, int elements)
{
var json = $$"""{"region":"HoldingRegisters","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":{{elements}}}""";
var (fc16, _) = await DriverEncodesAsync(json, ClrArray("Int16", elements), fc23: false);
var (fc23, _) = await DriverEncodesAsync(json, ClrArray("Int16", elements), fc23: true);
fc23.ShouldBe(fc16);
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldBe(fc23);
}
/// <summary>
/// A string-only tag (<c>addressString</c>, no <c>region</c> + <c>address</c> keys) is resolved by the DRIVER
/// with its own family. Core.Abstractions cannot parse that grammar (it references no driver assembly), so the
/// table cannot prove such an array writable and keeps it read-only — even though the Generic-family driver does
/// write this one. That is the conservative direction (a hidden write, never an advertised refusal); a tag that
/// needs to be array-writable is authored with the keys (the editor's "Resolve" pins them).
/// </summary>
[Theory]
[InlineData("""{"addressString":"40011:S::2","isArray":true,"arrayLength":2}""")]
[InlineData("""{"addressString":"40011:F:2"}""")]
[InlineData("""{"region":"HoldingRegisters","addressString":"40011:S::2","isArray":true,"arrayLength":2}""")] // region alone is not the keys
public async Task A_string_only_array_tag_stays_read_only(string tagConfig)
{
var (accepted, status) = await DriverEncodesAsync(tagConfig, ClrArray("Int16", 2));
accepted.ShouldBeTrue($"the Generic driver resolves and writes it (status 0x{status:X8}) — this pins the conservative gap, not a refusal");
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBeFalse();
}
/// <summary>
/// The ScadaBridge wire shape: a CLR <c>int[]</c> / <c>List&lt;int&gt;</c> to an Int16 holding-register array on
/// an Int16 node is advertised writable and CONVERTS element by element onto the wire (it is not exact-matched).
/// </summary>
[Fact]
public async Task A_ScadaBridge_int_list_to_an_Int16_array_encodes()
{
const string json = """{"region":"HoldingRegisters","address":10,"dataType":"Int16","isArray":true,"arrayLength":3}""";
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldBeTrue();
foreach (object value in new object[] { new[] { 1, -2, 300 }, new List<int> { 1, -2, 300 } })
{
var device = new ConformantDevice();
var drv = new ModbusDriver(
new ModbusDriverOptions { Host = "fake", RawTags = [new RawTagEntry(Ref, json, false)] }, "modbus-sb", _ => device);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.WriteAsync([new WriteRequest(Ref, value)], CancellationToken.None)).Single().StatusCode.ShouldBe(0u);
device.Holding[10..13].ShouldBe(new ushort[] { 1, 0xFFFE, 300 });
}
}
/// <summary>The two #741 U3 shapes encode end to end with their own node types: String[] on a String node and
/// BitInRegister[] on a Boolean node, each preserving what the wire must carry.</summary>
[Fact]
public async Task String_and_BitInRegister_arrays_encode_on_their_own_node_types()
{
const string strings = """{"region":"HoldingRegisters","address":10,"dataType":"String","stringLength":3,"isArray":true,"arrayLength":2}""";
const string bits = """{"region":"HoldingRegisters","address":20,"dataType":"BitInRegister","bitIndex":2,"isArray":true,"arrayLength":2}""";
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "String", strings).ShouldBeTrue();
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", bits).ShouldBeTrue();
var device = new ConformantDevice();
device.Holding[20] = 0x0001;
device.Holding[21] = 0x0005;
var drv = new ModbusDriver(
new ModbusDriverOptions { Host = "fake", RawTags = [new RawTagEntry("s", strings, false), new RawTagEntry("b", bits, false)] },
"modbus-u3", _ => device);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.WriteAsync(
[new WriteRequest("s", new[] { "ABC", "de" }), new WriteRequest("b", new[] { true, false })], CancellationToken.None);
r.Select(x => x.StatusCode).ShouldBe(new[] { 0u, 0u });
device.Holding[10..14].ShouldBe(new ushort[] { 0x4142, 0x4300, 0x6465, 0x0000 }); // 2 regs per 3-char element, nul-padded
device.Holding[20..22].ShouldBe(new ushort[] { 0x0005, 0x0001 }); // bit 2 set / cleared, other bits kept
}
/// <summary>
/// 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
{
public readonly ushort[] Holding = new ushort[65536];
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 (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] = 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:
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;
return Task.FromResult(pdu);
case 0x06:
WritePdus++;
Holding[addr] = (ushort)((pdu[3] << 8) | pdu[4]);
return Task.FromResult(pdu);
case 0x0F:
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:
{
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]);
return Task.FromResult(ReadRegs(0x17, addr, qty));
}
default:
return Refuse(fc, 0x01);
}
}
private byte[] ReadRegs(byte fc, int addr, int qty)
{
var resp = new byte[2 + qty * 2];
resp[0] = fc; resp[1] = (byte)(qty * 2);
for (var i = 0; i < qty; i++)
{
resp[2 + i * 2] = (byte)(Holding[addr + i] >> 8);
resp[3 + i * 2] = (byte)(Holding[addr + i] & 0xFF);
}
return resp;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}