cf62007678
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
435 lines
30 KiB
C#
435 lines
30 KiB
C#
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<int></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;
|
||
}
|
||
}
|