using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// Gitea #610 / #741 — pins '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 /// 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). /// /// /// "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 MaxRegistersPerWrite below 123 is the driver's own. FC23 /// is opt-in and never changes the answer (pinned below). /// [Trait("Category", "Unit")] public sealed class ModbusArrayWriteSupportParityTests { private const string Ref = "cell/modbus/arr"; /// 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). 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"]; /// Drives the real driver: true iff the write is Good AND a write PDU reached the device. 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 TypeMatrix() { var data = new TheoryData(); foreach (var region in Enum.GetValues()) foreach (var dt in Enum.GetValues()) foreach (var node in NodeTypes) data.Add(region, dt, node); return data; } /// 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). [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}"); } /// Positive control: the matrix above is not vacuously one answer, and it contains the shapes #741 added. [Fact] public void The_matrix_contains_both_answers() { var answers = Enum.GetValues() .SelectMany(r => Enum.GetValues().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); } /// /// The shape edges, both directions, against the real write path: the FC16 cap for numeric, String and /// BitInRegister arrays (default 123, or a configured MaxRegistersPerWrite), the FC15 ceiling, a /// missing / zero arrayLength (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. /// [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}"); } /// 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. [Fact] public async Task Every_byte_order_member_agrees_with_the_driver() { foreach (var order in Enum.GetValues()) foreach (var stringOrder in Enum.GetValues()) { 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); } } /// /// The protocol ceilings and the configured caps together, against the real write path: /// /// a maxRegistersPerWrite 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); /// a BitInRegister[] write reads its span first in ONE unchunked FC03, so it is also bounded by /// maxRegistersPerRead (and FC03's 125); numeric arrays never read on write; /// every span ends at or before address 65535 (exception 02 past it). /// /// [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(); 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}"); } /// Positive control for the edges: each cap boundary has a writable side and a refused side in the table. [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(); } /// /// FC23 is opt-in and never changes writability: with UseFC23ForMultiRegisterWrites 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. /// [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); } /// /// A string-only tag (addressString, no region + address 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). /// [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(); } /// /// The ScadaBridge wire shape: a CLR int[] / List<int> 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). /// [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 { 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 }); } } /// 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. [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 } /// /// 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. /// private sealed class ConformantDevice : IModbusTransport { public readonly ushort[] Holding = new ushort[65536]; public readonly bool[] Coils = new bool[65536]; public int WritePdus; /// The device's FC03/FC04 quantity limit: the protocol's 125, or a smaller device-specific one. public int MaxReadRegisters { get; init; } = 125; public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; private static Task Refuse(byte fc, byte code) => Task.FromException(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 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; } }