Merge PR #26: feat(solarman): add Solarman V5 transport for Modbus RTU

This commit is contained in:
root committed 2026-10-24 00:34:33 +02:00
commit 6c6f071a80
4 files changed
+554

No files matched your search

@@ -0,0 +1,54 @@
package home.gateway.adapters.solarman;
import java.io.IOException;
/** Modbus RTU frame: unit id, PDU, CRC16 with the low byte first. */
final class ModbusRtuFrame {
private static final int CRC_LENGTH = 2;
private static final int MIN_LENGTH = 1 + 1 + CRC_LENGTH;
private ModbusRtuFrame() {
}
static byte[] build(int unitId, byte[] pdu) {
byte[] frame = new byte[1 + pdu.length + CRC_LENGTH];
frame[0] = (byte) unitId;
System.arraycopy(pdu, 0, frame, 1, pdu.length);
int crc = crc16(frame, frame.length - CRC_LENGTH);
frame[frame.length - 2] = (byte) crc;
frame[frame.length - 1] = (byte) (crc >> 8);
return frame;
}
static boolean hasValidCrc(byte[] frame) {
if (frame.length < MIN_LENGTH) {
return false;
}
int crc = crc16(frame, frame.length - CRC_LENGTH);
return frame[frame.length - 2] == (byte) crc && frame[frame.length - 1] == (byte) (crc >> 8);
}
static byte[] parse(byte[] frame, int expectedUnitId) throws IOException {
if (!hasValidCrc(frame)) {
throw new IOException("Modbus RTU frame has a bad CRC or is too short");
}
if ((frame[0] & 0xFF) != expectedUnitId) {
throw new IOException("Modbus RTU frame from unit " + (frame[0] & 0xFF) + ", expected " + expectedUnitId);
}
byte[] pdu = new byte[frame.length - 1 - CRC_LENGTH];
System.arraycopy(frame, 1, pdu, 0, pdu.length);
return pdu;
}
static int crc16(byte[] data, int length) {
int crc = 0xFFFF;
for (int i = 0; i < length; i++) {
crc ^= data[i] & 0xFF;
for (int bit = 0; bit < 8; bit++) {
crc = (crc & 1) != 0 ? (crc >>> 1) ^ 0xA001 : crc >>> 1;
}
}
return crc;
}
}
@@ -0,0 +1,176 @@
package home.gateway.adapters.solarman;
import home.gateway.adapters.modbus.ModbusTransport;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.time.Duration;
import java.util.Arrays;
/**
* Modbus RTU carried in Solarman V5 frames over the Wi-Fi logger's TCP port (usually 8899).
* One connection is kept and reused, one request at a time; after any error it is closed and
* the next call reconnects. All V5 fields are little endian, the RTU frame inside is not.
*/
public final class SolarmanV5Transport implements ModbusTransport {
public record Settings(String host, int port, long loggerSerial, Duration connectTimeout, Duration readTimeout) {
}
private static final int START = 0xA5;
private static final int END = 0x15;
private static final int REQUEST_CONTROL_CODE = 0x4510;
private static final int RESPONSE_CONTROL_CODE = 0x1510;
private static final int HEADER_LENGTH = 11;
private static final int TRAILER_LENGTH = 2;
private static final int REQUEST_PAYLOAD_PREFIX = 15;
private static final int RESPONSE_PAYLOAD_PREFIX = 14;
private static final int INVERTER_FRAME_TYPE = 0x02;
private static final int DOUBLE_CRC_LENGTH = 2;
private final Settings settings;
private Socket socket;
private int sequence = (int) (Math.random() * 256);
public SolarmanV5Transport(Settings settings) {
this.settings = settings;
}
@Override
public synchronized byte[] exchange(int unitId, byte[] requestPdu) throws IOException {
try {
Socket connection = connection();
int requestSequence = nextSequence();
OutputStream out = connection.getOutputStream();
out.write(requestFrame(requestSequence, ModbusRtuFrame.build(unitId, requestPdu)));
out.flush();
byte[] rtu = readResponseRtu(new DataInputStream(connection.getInputStream()), requestSequence);
return ModbusRtuFrame.parse(rtu, unitId);
} catch (IOException | RuntimeException e) {
closeConnection();
throw e;
}
}
@Override
public synchronized void close() {
closeConnection();
}
private Socket connection() throws IOException {
if (socket == null) {
Socket candidate = new Socket();
try {
candidate.connect(new InetSocketAddress(settings.host(), settings.port()),
(int) settings.connectTimeout().toMillis());
candidate.setSoTimeout((int) settings.readTimeout().toMillis());
} catch (IOException e) {
candidate.close();
throw e;
}
socket = candidate;
}
return socket;
}
private void closeConnection() {
if (socket != null) {
try {
socket.close();
} catch (IOException ignored) {
// already failing; the next call reconnects
}
socket = null;
}
}
// Only the low byte is echoed by the logger; it counts the high byte itself.
private int nextSequence() {
sequence = (sequence + 1) & 0xFF;
return sequence;
}
private byte[] requestFrame(int requestSequence, byte[] rtu) {
int payloadLength = REQUEST_PAYLOAD_PREFIX + rtu.length;
ByteBuffer frame = ByteBuffer.allocate(HEADER_LENGTH + payloadLength + TRAILER_LENGTH)
.order(ByteOrder.LITTLE_ENDIAN);
frame.put((byte) START)
.putShort((short) payloadLength)
.putShort((short) REQUEST_CONTROL_CODE)
.putShort((short) requestSequence)
.putInt((int) settings.loggerSerial())
.put((byte) INVERTER_FRAME_TYPE)
.put(new byte[REQUEST_PAYLOAD_PREFIX - 1]) // sensor type and three timers, all zero
.put(rtu);
frame.put((byte) checksum(frame.array(), frame.position()));
frame.put((byte) END);
return frame.array();
}
private byte[] readResponseRtu(DataInputStream in, int requestSequence) throws IOException {
byte[] header = new byte[HEADER_LENGTH];
in.readFully(header);
ByteBuffer head = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);
int payloadLength = head.getShort(1) & 0xFFFF;
if ((header[0] & 0xFF) != START) {
throw new IOException("V5 response does not start with 0xA5");
}
if (payloadLength < RESPONSE_PAYLOAD_PREFIX) {
throw new IOException("V5 response payload too short: " + payloadLength);
}
byte[] rest = new byte[payloadLength + TRAILER_LENGTH];
in.readFully(rest);
verify(head, header, rest, requestSequence);
byte[] rtu = new byte[payloadLength - RESPONSE_PAYLOAD_PREFIX];
System.arraycopy(rest, RESPONSE_PAYLOAD_PREFIX, rtu, 0, rtu.length);
return withoutDoubleCrc(rtu);
}
private void verify(ByteBuffer head, byte[] header, byte[] rest, int requestSequence) throws IOException {
int controlCode = head.getShort(3) & 0xFFFF;
if (controlCode != RESPONSE_CONTROL_CODE) {
throw new IOException("Unexpected V5 control code 0x" + Integer.toHexString(controlCode));
}
if ((head.getShort(5) & 0xFF) != requestSequence) {
throw new IOException("V5 response answers another request");
}
if (head.getInt(7) != (int) settings.loggerSerial()) {
throw new IOException("V5 response from another logger");
}
byte[] checked = new byte[header.length + rest.length - TRAILER_LENGTH];
System.arraycopy(header, 0, checked, 0, header.length);
System.arraycopy(rest, 0, checked, header.length, rest.length - TRAILER_LENGTH);
if ((rest[rest.length - 2] & 0xFF) != checksum(checked, checked.length)) {
throw new IOException("V5 response has a bad checksum");
}
if ((rest[rest.length - 1] & 0xFF) != END) {
throw new IOException("V5 response does not end with 0x15");
}
}
// Deye loggers append a second, always zero CRC. Check the trimmed frame first: a frame
// followed by its own CRC also passes the CRC check, with the CRC taken as payload.
private static byte[] withoutDoubleCrc(byte[] rtu) {
if (rtu.length > DOUBLE_CRC_LENGTH) {
byte[] trimmed = Arrays.copyOf(rtu, rtu.length - DOUBLE_CRC_LENGTH);
if (ModbusRtuFrame.hasValidCrc(trimmed)) {
return trimmed;
}
}
return rtu;
}
/** Sum of all bytes between the start byte and the checksum, modulo 256. */
private static int checksum(byte[] frame, int end) {
int sum = 0;
for (int i = 1; i < end; i++) {
sum += frame[i] & 0xFF;
}
return sum & 0xFF;
}
}
@@ -0,0 +1,47 @@
package home.gateway.adapters.solarman;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import org.junit.jupiter.api.Test;
class ModbusRtuFrameTest {
private static final byte[] READ_10_AT_0 = {0x01, 0x03, 0x00, 0x00, 0x00, 0x0A, (byte) 0xC5, (byte) 0xCD};
@Test
void buildsTheStandardExampleWithCrcLowByteFirst() {
byte[] pdu = {0x03, 0x00, 0x00, 0x00, 0x0A};
assertArrayEquals(READ_10_AT_0, ModbusRtuFrame.build(1, pdu));
}
@Test
void crcOfAKnownResponse() {
byte[] frame = {0x01, 0x04, 0x02, (byte) 0xFF, (byte) 0xFF};
assertEquals(0x80B8, ModbusRtuFrame.crc16(frame, frame.length));
}
@Test
void parsesBackThePdu() throws IOException {
assertArrayEquals(new byte[] {0x03, 0x00, 0x00, 0x00, 0x0A}, ModbusRtuFrame.parse(READ_10_AT_0, 1));
}
@Test
void rejectsBadCrc() {
byte[] frame = READ_10_AT_0.clone();
frame[7] ^= 1;
assertThrows(IOException.class, () -> ModbusRtuFrame.parse(frame, 1));
}
@Test
void rejectsOtherUnit() {
assertThrows(IOException.class, () -> ModbusRtuFrame.parse(READ_10_AT_0, 2));
}
@Test
void rejectsTooShortFrame() {
assertThrows(IOException.class, () -> ModbusRtuFrame.parse(new byte[] {0x01, 0x03}, 1));
}
}
@@ -0,0 +1,277 @@
package home.gateway.adapters.solarman;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.DataInputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Function;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class SolarmanV5TransportTest {
private static final long SERIAL = 3112107147L;
private static final int UNIT = 1;
private static final byte[] READ_REQUEST = {0x03, 0x00, 0x00, 0x00, 0x02};
private static final byte[] READ_RESPONSE = {0x03, 0x04, 0x00, 0x2A, 0x01, (byte) 0xF4};
private FakeLogger logger;
private SolarmanV5Transport transport;
@BeforeEach
void start() throws IOException {
logger = new FakeLogger();
transport = new SolarmanV5Transport(new SolarmanV5Transport.Settings(
"localhost", logger.port(), SERIAL, Duration.ofSeconds(2), Duration.ofMillis(500)));
}
@AfterEach
void stop() throws IOException {
transport.close();
logger.close();
}
@Test
void sendsDocumentedRequestFrame() throws IOException {
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, READ_RESPONSE)));
transport.exchange(UNIT, READ_REQUEST);
byte[] frame = logger.requests.get(0);
byte[] rtu = ModbusRtuFrame.build(UNIT, READ_REQUEST);
ByteBuffer buffer = ByteBuffer.wrap(frame).order(ByteOrder.LITTLE_ENDIAN);
assertEquals(28 + rtu.length, frame.length);
assertEquals((byte) 0xA5, frame[0]);
assertEquals(15 + rtu.length, buffer.getShort(1));
assertEquals(0x4510, buffer.getShort(3));
assertEquals(SERIAL, buffer.getInt(7) & 0xFFFFFFFFL);
assertEquals(0x02, frame[11]);
for (int i = 12; i < 26; i++) {
assertEquals(0, frame[i], "timer byte " + i);
}
assertArrayEquals(rtu, Arrays.copyOfRange(frame, 26, 26 + rtu.length));
assertEquals(sum(frame, 1, frame.length - 2), frame[frame.length - 2] & 0xFF);
assertEquals(0x15, frame[frame.length - 1]);
}
@Test
void returnsTheResponsePdu() throws IOException {
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, READ_RESPONSE)));
assertArrayEquals(READ_RESPONSE, transport.exchange(UNIT, READ_REQUEST));
}
@Test
void acceptsTheDoubleCrcOfDeyeLoggers() throws IOException {
byte[] rtu = ModbusRtuFrame.build(UNIT, READ_RESPONSE);
byte[] doubled = Arrays.copyOf(rtu, rtu.length + 2);
logger.respondWith(request -> response(request, doubled));
assertArrayEquals(READ_RESPONSE, transport.exchange(UNIT, READ_REQUEST));
}
@Test
void reusesOneConnectionForSuccessiveRequests() throws IOException {
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, READ_RESPONSE)));
transport.exchange(UNIT, READ_REQUEST);
transport.exchange(UNIT, READ_REQUEST);
assertEquals(1, logger.connections);
assertEquals(2, logger.requests.size());
}
@Test
void usesIncreasingSequenceNumbers() throws IOException {
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, READ_RESPONSE)));
transport.exchange(UNIT, READ_REQUEST);
transport.exchange(UNIT, READ_REQUEST);
int first = logger.requests.get(0)[5] & 0xFF;
int second = logger.requests.get(1)[5] & 0xFF;
assertEquals((first + 1) & 0xFF, second);
}
@Test
void rejectsBadChecksumAndReconnects() throws IOException {
logger.respondWith(request -> {
byte[] bad = response(request, ModbusRtuFrame.build(UNIT, READ_RESPONSE));
bad[bad.length - 2] ^= 1;
return bad;
});
assertThrows(IOException.class, () -> transport.exchange(UNIT, READ_REQUEST));
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, READ_RESPONSE)));
assertArrayEquals(READ_RESPONSE, transport.exchange(UNIT, READ_REQUEST));
assertEquals(2, logger.connections);
}
@Test
void rejectsBadRtuCrc() {
byte[] rtu = ModbusRtuFrame.build(UNIT, READ_RESPONSE);
rtu[rtu.length - 1] ^= 1;
logger.respondWith(request -> response(request, rtu));
assertThrows(IOException.class, () -> transport.exchange(UNIT, READ_REQUEST));
}
@Test
void rejectsWrongControlCode() {
logger.respondWith(request -> {
byte[] frame = response(request, ModbusRtuFrame.build(UNIT, READ_RESPONSE));
frame[4] = 0x17;
return withFixedChecksum(frame);
});
assertThrows(IOException.class, () -> transport.exchange(UNIT, READ_REQUEST));
}
@Test
void rejectsResponseToAnotherSequence() {
logger.respondWith(request -> {
request[5]++;
return response(request, ModbusRtuFrame.build(UNIT, READ_RESPONSE));
});
assertThrows(IOException.class, () -> transport.exchange(UNIT, READ_REQUEST));
}
@Test
void rejectsResponseFromAnotherUnit() {
logger.respondWith(request -> response(request, ModbusRtuFrame.build(2, READ_RESPONSE)));
assertThrows(IOException.class, () -> transport.exchange(UNIT, READ_REQUEST));
}
@Test
void reconnectsAfterTheLoggerDropsTheConnection() throws IOException {
logger.respondWith(request -> null);
assertThrows(IOException.class, () -> transport.exchange(UNIT, READ_REQUEST));
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, READ_RESPONSE)));
assertArrayEquals(READ_RESPONSE, transport.exchange(UNIT, READ_REQUEST));
assertEquals(2, logger.connections);
}
@Test
void timesOutWhenTheLoggerStaysSilent() {
logger.respondWith(request -> new byte[0]);
assertThrows(IOException.class, () -> transport.exchange(UNIT, READ_REQUEST));
}
/** A V5 response as the logger sends it: echoes sequence and serial, status 1, three timers. */
private static byte[] response(byte[] request, byte[] rtu) {
ByteBuffer frame = ByteBuffer.allocate(11 + 14 + rtu.length + 2).order(ByteOrder.LITTLE_ENDIAN);
frame.put((byte) 0xA5)
.putShort((short) (14 + rtu.length))
.putShort((short) 0x1510)
.put(request, 5, 2)
.put(request, 7, 4)
.put((byte) 0x02)
.put((byte) 0x01)
.putInt(100)
.putInt(200)
.putInt(300)
.put(rtu)
.put((byte) 0)
.put((byte) 0x15);
return withFixedChecksum(frame.array());
}
private static byte[] withFixedChecksum(byte[] frame) {
frame[frame.length - 2] = (byte) sum(frame, 1, frame.length - 2);
return frame;
}
private static int sum(byte[] frame, int from, int to) {
int total = 0;
for (int i = from; i < to; i++) {
total += frame[i] & 0xFF;
}
return total & 0xFF;
}
/** Local stand-in for the Wi-Fi logger. A null answer drops the connection, an empty one stays silent. */
private static final class FakeLogger implements AutoCloseable {
final List<byte[]> requests = new CopyOnWriteArrayList<>();
volatile int connections;
private final ServerSocket server = new ServerSocket(0);
private final List<Socket> accepted = new CopyOnWriteArrayList<>();
private volatile Function<byte[], byte[]> responder = request -> null;
FakeLogger() throws IOException {
Thread.ofVirtual().start(this::acceptLoop);
}
int port() {
return server.getLocalPort();
}
void respondWith(Function<byte[], byte[]> responder) {
this.responder = responder;
}
private void acceptLoop() {
try {
while (true) {
Socket socket = server.accept();
accepted.add(socket);
connections++;
Thread.ofVirtual().start(() -> serve(socket));
}
} catch (IOException closed) {
// server closed at the end of the test
}
}
private void serve(Socket socket) {
try (socket) {
DataInputStream in = new DataInputStream(socket.getInputStream());
while (true) {
byte[] request = readRequest(in);
requests.add(request.clone());
byte[] answer = responder.apply(request);
if (answer == null) {
return;
}
socket.getOutputStream().write(answer);
}
} catch (IOException closed) {
// client went away
}
}
private static byte[] readRequest(DataInputStream in) throws IOException {
byte[] header = new byte[11];
in.readFully(header);
int payload = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN).getShort(1);
byte[] frame = new byte[11 + payload + 2];
System.arraycopy(header, 0, frame, 0, 11);
in.readFully(frame, 11, payload + 2);
return frame;
}
@Override
public void close() throws IOException {
server.close();
for (Socket socket : new ArrayList<>(accepted)) {
socket.close();
}
}
}
}