feat(gateway): write multiple holding registers (FC16)
CI / Check (gateway, backend, frontend) (push) Successful in 1m21s
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:
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)));
|
||||
|
||||
Reference in new issue
Block a user