feat(gateway): write multiple holding registers (FC16)
CI / Check (gateway, backend, frontend) (push) Successful in 1m21s

Needed for the Deye's program table; works over Solarman V5 unchanged,
as the RTU frame wraps any PDU. The echo of address and count is checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-24 15:03:29 +02:00
1 parent 965dfae644
commit 8111405326
12 files changed
+223 -3

No files matched your search

@@ -2,7 +2,7 @@ package home.gateway.adapters.modbus;
import java.io.IOException;
/** Register reads and single-register writes; values are raw 16-bit words (0–65535), decoding belongs to the device. */
/** Register reads and writes; values are raw 16-bit words (0–65535), decoding belongs to the device. */
public interface ModbusClient {
int[] readHoldingRegisters(int unitId, int address, int count) throws IOException;
@@ -11,4 +11,7 @@ public interface ModbusClient {
/** FC6: succeeds only if the device echoed the address and value. */
void writeSingleRegister(int unitId, int address, int value) throws IOException;
/** FC16, 1 to 123 consecutive registers: succeeds only if the device echoed the address and count. */
void writeMultipleRegisters(int unitId, int address, int[] values) throws IOException;
}
@@ -5,14 +5,16 @@ import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.HexFormat;
/** Modbus functions FC3, FC4 and FC6 encoded and decoded as PDUs over a transport. */
/** Modbus functions FC3, FC4, FC6 and FC16 encoded and decoded as PDUs over a transport. */
public class PduModbusClient implements ModbusClient {
private static final int READ_HOLDING_REGISTERS = 0x03;
private static final int READ_INPUT_REGISTERS = 0x04;
private static final int WRITE_SINGLE_REGISTER = 0x06;
private static final int WRITE_MULTIPLE_REGISTERS = 0x10;
private static final int EXCEPTION_FLAG = 0x80;
private static final int MAX_QUANTITY = 125;
private static final int MAX_WRITE_QUANTITY = 123;
private static final int MAX_ADDRESS = 0xFFFF;
private static final int MAX_VALUE = 0xFFFF;
@@ -48,6 +50,32 @@ public class PduModbusClient implements ModbusClient {
}
}
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) throws IOException {
checkAddress(address);
if (values.length < 1 || values.length > MAX_WRITE_QUANTITY) {
throw new IllegalArgumentException("Register count out of range 1..123: " + values.length);
}
checkAddress(address + values.length - 1);
ByteBuffer request = ByteBuffer.allocate(6 + 2 * values.length)
.put((byte) WRITE_MULTIPLE_REGISTERS).putShort((short) address)
.putShort((short) values.length).put((byte) (2 * values.length));
for (int value : values) {
if (value < 0 || value > MAX_VALUE) {
throw new IllegalArgumentException("Register value out of range 0..65535: " + value);
}
request.putShort((short) value);
}
byte[] response = transport.exchange(unitId, request.array());
throwIfException(WRITE_MULTIPLE_REGISTERS, response);
// The response repeats function, address and quantity: the first five bytes of the request.
byte[] expected = Arrays.copyOf(request.array(), 5);
if (!Arrays.equals(expected, response)) {
throw new IOException("Modbus write echo %s does not match request %s"
.formatted(HexFormat.of().formatHex(response), HexFormat.of().formatHex(expected)));
}
}
private int[] read(int function, int unitId, int address, int count) throws IOException {
checkAddress(address);
if (count < 1 || count > MAX_QUANTITY) {
@@ -21,6 +21,11 @@ class DeyeDeviceTest {
/** Serves raw words by register and records every request. */
private static class FakeInverter implements ModbusClient {
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) {
throw new AssertionError("not expected");
}
@Override
public void writeSingleRegister(int unitId, int address, int value) {
throw new UnsupportedOperationException();
@@ -54,6 +54,11 @@ class DeyeSettingsDeviceTest {
throw new AssertionError("the Deye is read with FC3 only");
}
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) {
throw new AssertionError("not expected");
}
@Override
public void writeSingleRegister(int unitId, int address, int value) {
throw new AssertionError("the settings are never written");
@@ -146,6 +146,11 @@ class HeatPumpDeviceTest {
}
private static final class FakeController implements ModbusClient {
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) {
throw new AssertionError("not expected");
}
@Override
public void writeSingleRegister(int unitId, int address, int value) {
throw new UnsupportedOperationException();
@@ -87,6 +87,11 @@ class ModbusSetpointRegistersTest {
throw new UnsupportedOperationException();
}
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) {
throw new AssertionError("not expected");
}
@Override
public void writeSingleRegister(int unitId, int address, int value) throws IOException {
if (failure != null) {
@@ -173,6 +173,11 @@ class HouseMeterDeviceTest {
/** A meter whose input registers hold float32 values, big-endian words; unset ones are 0. */
private static final class FakeMeter implements ModbusClient {
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) {
throw new AssertionError("not expected");
}
@Override
public void writeSingleRegister(int unitId, int address, int value) {
throw new UnsupportedOperationException();
@@ -16,7 +16,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Local Modbus fake answering FC3, FC4 and FC6; unknown addresses get exception code 2. It speaks
* Local Modbus fake answering FC3, FC4, FC6 and FC16; unknown addresses get exception code 2. It speaks
* Modbus TCP, or RTU frames over TCP when made with {@link #rtuOverTcp()}.
*/
public final class MockModbusServer implements Closeable {
@@ -31,6 +31,7 @@ public final class MockModbusServer implements Closeable {
private final AtomicInteger connections = new AtomicInteger();
private final AtomicInteger open = new AtomicInteger();
private final List<int[]> writes = new CopyOnWriteArrayList<>();
private final List<int[]> multipleWrites = new CopyOnWriteArrayList<>();
private volatile Duration responseDelay = Duration.ZERO;
private volatile boolean dropNextRequest;
private volatile boolean wrongTransactionId;
@@ -80,6 +81,11 @@ public final class MockModbusServer implements Closeable {
return writes;
}
/** Every FC16 request received, as {unitId, address, value, value, ...}. */
public List<int[]> multipleWrites() {
return multipleWrites;
}
/** Echoes the next write with the value off by one. */
void echoWrongValueOnNextWrite() {
wrongWriteEcho = true;
@@ -222,6 +228,9 @@ public final class MockModbusServer implements Closeable {
if (function == 6) {
return write(unitId, pdu);
}
if (function == 16) {
return writeMultiple(unitId, pdu);
}
Map<Integer, Integer> registers = switch (function) {
case 3 -> holding;
case 4 -> input;
@@ -260,6 +269,29 @@ public final class MockModbusServer implements Closeable {
return pdu;
}
private byte[] writeMultiple(int unitId, byte[] pdu) {
if (writeException != 0) {
return new byte[] {(byte) 0x90, (byte) writeException};
}
ByteBuffer request = ByteBuffer.wrap(pdu, 1, pdu.length - 1);
int address = request.getShort() & 0xFFFF;
int count = request.getShort() & 0xFFFF;
request.get();
int[] record = new int[2 + count];
record[0] = unitId;
record[1] = address;
for (int i = 0; i < count; i++) {
record[2 + i] = request.getShort() & 0xFFFF;
holding.put(address + i, record[2 + i]);
}
multipleWrites.add(record);
if (wrongWriteEcho) {
wrongWriteEcho = false;
return ByteBuffer.allocate(5).put(pdu, 0, 3).putShort((short) (count + 1)).array();
}
return Arrays.copyOf(pdu, 5);
}
private static byte[] frame(int transactionId, int unitId, byte[] pdu) {
return ByteBuffer.allocate(7 + pdu.length)
.putShort((short) transactionId).putShort((short) 0)
@@ -178,4 +178,50 @@ class PduModbusClientTest {
assertEquals(6, e.function());
assertEquals(3, e.code());
}
@Test
void writesMultipleRegistersAndAcceptsTheEcho() throws IOException {
answering(0x10, 0x00, 0x94, 0x00, 0x02).writeMultipleRegisters(1, 148, new int[] {100, 0xFFFF});
assertEquals(1, lastUnit);
assertArrayEquals(new byte[] {0x10, 0x00, (byte) 0x94, 0, 2, 4, 0, 100, (byte) 0xFF, (byte) 0xFF},
lastRequest);
}
@Test
void rejectsMultipleWriteOutOfRangeBeforeSending() {
PduModbusClient client = answering();
assertThrows(IllegalArgumentException.class, () -> client.writeMultipleRegisters(1, 0, new int[0]));
assertThrows(IllegalArgumentException.class, () -> client.writeMultipleRegisters(1, 0, new int[124]));
assertThrows(IllegalArgumentException.class, () -> client.writeMultipleRegisters(1, 0, new int[] {-1}));
assertThrows(IllegalArgumentException.class, () -> client.writeMultipleRegisters(1, 0, new int[] {65536}));
assertThrows(IllegalArgumentException.class, () -> client.writeMultipleRegisters(1, -1, new int[] {1}));
assertThrows(IllegalArgumentException.class,
() -> client.writeMultipleRegisters(1, 65535, new int[] {1, 2}));
assertNull(lastRequest);
}
@Test
void rejectsMultipleWriteEchoWithOtherAddressQuantityFunctionOrLength() {
assertThrows(IOException.class,
() -> answering(0x10, 0x00, 0x95, 0x00, 0x02).writeMultipleRegisters(1, 148, new int[] {1, 2}));
assertThrows(IOException.class,
() -> answering(0x10, 0x00, 0x94, 0x00, 0x01).writeMultipleRegisters(1, 148, new int[] {1, 2}));
assertThrows(IOException.class,
() -> answering(0x06, 0x00, 0x94, 0x00, 0x02).writeMultipleRegisters(1, 148, new int[] {1, 2}));
assertThrows(IOException.class,
() -> answering(0x10, 0x00, 0x94, 0x00).writeMultipleRegisters(1, 148, new int[] {1, 2}));
assertThrows(IOException.class,
() -> answering(0x10, 0x00, 0x94, 0x00, 0x02, 0x00).writeMultipleRegisters(1, 148, new int[] {1, 2}));
assertThrows(IOException.class, () -> answering().writeMultipleRegisters(1, 148, new int[] {1, 2}));
}
@Test
void reportsMultipleWriteExceptionResponse() {
ModbusException e = assertThrows(ModbusException.class,
() -> answering(0x90, 0x02).writeMultipleRegisters(1, 148, new int[] {1}));
assertEquals(16, e.function());
assertEquals(2, e.code());
}
}
@@ -173,4 +173,35 @@ class TcpTransportTest {
assertThrows(IOException.class, () -> new PduModbusClient(transport).writeSingleRegister(1, 4143, 9));
}
@Test
void writesMultipleRegistersThatTheServerStoresAndReadsBack() throws IOException {
var modbus = new PduModbusClient(transport);
modbus.writeMultipleRegisters(1, 148, new int[] {100, 500, 900});
assertEquals(1, server.multipleWrites().size());
assertArrayEquals(new int[] {1, 148, 100, 500, 900}, server.multipleWrites().get(0));
assertArrayEquals(new int[] {100, 500, 900}, modbus.readHoldingRegisters(1, 148, 3));
}
@Test
void reportsAMultipleWriteTheServerRefuses() {
server.rejectWritesWith(3);
var e = assertThrows(ModbusException.class,
() -> new PduModbusClient(transport).writeMultipleRegisters(1, 148, new int[] {100}));
assertEquals(16, e.function());
assertEquals(3, e.code());
assertTrue(server.multipleWrites().isEmpty());
}
@Test
void rejectsAMultipleWriteThatTheServerEchoesWrongly() {
server.echoWrongValueOnNextWrite();
assertThrows(IOException.class,
() -> new PduModbusClient(transport).writeMultipleRegisters(1, 148, new int[] {100, 200}));
}
}
@@ -17,6 +17,18 @@ class ModbusRtuFrameTest {
assertArrayEquals(READ_10_AT_0, ModbusRtuFrame.build(1, pdu));
}
@Test
void buildsAMultipleWriteRequestAndItsResponse() throws IOException {
byte[] request = {0x10, 0x00, 0x01, 0x00, 0x02, 0x04, 0x00, 0x0A, 0x01, 0x02};
byte[] response = {0x10, 0x00, 0x01, 0x00, 0x02};
assertArrayEquals(new byte[] {0x11, 0x10, 0x00, 0x01, 0x00, 0x02, 0x04, 0x00, 0x0A, 0x01, 0x02,
(byte) 0xC6, (byte) 0xF0}, ModbusRtuFrame.build(0x11, request));
assertArrayEquals(new byte[] {0x11, 0x10, 0x00, 0x01, 0x00, 0x02, 0x12, (byte) 0x98},
ModbusRtuFrame.build(0x11, response));
assertArrayEquals(response, ModbusRtuFrame.parse(ModbusRtuFrame.build(0x11, response), 0x11));
}
@Test
void crcOfAKnownResponse() {
byte[] frame = {0x01, 0x04, 0x02, (byte) 0xFF, (byte) 0xFF};
@@ -5,6 +5,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import home.gateway.adapters.modbus.ModbusException;
import home.gateway.adapters.modbus.PduModbusClient;
import java.io.DataInputStream;
import java.io.IOException;
import java.net.ServerSocket;
@@ -84,6 +87,46 @@ class SolarmanV5TransportTest {
assertArrayEquals(READ_RESPONSE, transport.exchange(UNIT, READ_REQUEST));
}
@Test
void carriesAMultipleRegisterWriteAndItsEcho() throws IOException {
byte[] echo = {0x10, 0x00, (byte) 0xA6, 0x00, 0x02};
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, echo)));
new PduModbusClient(transport).writeMultipleRegisters(UNIT, 166, new int[] {80, 90});
byte[] pdu = {0x10, 0x00, (byte) 0xA6, 0x00, 0x02, 0x04, 0x00, 0x50, 0x00, 0x5A};
byte[] rtu = ModbusRtuFrame.build(UNIT, pdu);
byte[] frame = logger.requests.get(0);
assertArrayEquals(rtu, Arrays.copyOfRange(frame, 26, 26 + rtu.length));
}
@Test
void reportsTheExceptionAnswerToAMultipleRegisterWrite() {
byte[] exception = {(byte) 0x90, 0x02};
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, exception)));
var e = assertThrows(ModbusException.class,
() -> new PduModbusClient(transport).writeMultipleRegisters(UNIT, 166, new int[] {80}));
assertEquals(16, e.function());
assertEquals(2, e.code());
}
@Test
void rejectsAShortOrGarbledAnswerToAMultipleRegisterWrite() throws IOException {
var modbus = new PduModbusClient(transport);
byte[] shortEcho = {0x10, 0x00, (byte) 0xA6};
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, shortEcho)));
assertThrows(IOException.class, () -> modbus.writeMultipleRegisters(UNIT, 166, new int[] {80}));
byte[] otherCount = {0x10, 0x00, (byte) 0xA6, 0x00, 0x03};
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, otherCount)));
assertThrows(IOException.class, () -> modbus.writeMultipleRegisters(UNIT, 166, new int[] {80}));
logger.respondWith(request -> response(request, new byte[] {UNIT, 0x10, 0x00}));
assertThrows(IOException.class, () -> modbus.writeMultipleRegisters(UNIT, 166, new int[] {80}));
}
@Test
void reusesOneConnectionForSuccessiveRequests() throws IOException {
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, READ_RESPONSE)));