diff --git a/src/main/java/home/gateway/adapters/solarman/ModbusRtuFrame.java b/src/main/java/home/gateway/adapters/solarman/ModbusRtuFrame.java new file mode 100644 index 0000000..3329c24 --- /dev/null +++ b/src/main/java/home/gateway/adapters/solarman/ModbusRtuFrame.java @@ -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; + } +} diff --git a/src/main/java/home/gateway/adapters/solarman/SolarmanV5Transport.java b/src/main/java/home/gateway/adapters/solarman/SolarmanV5Transport.java new file mode 100644 index 0000000..cef249e --- /dev/null +++ b/src/main/java/home/gateway/adapters/solarman/SolarmanV5Transport.java @@ -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; + } +} diff --git a/src/test/java/home/gateway/adapters/solarman/ModbusRtuFrameTest.java b/src/test/java/home/gateway/adapters/solarman/ModbusRtuFrameTest.java new file mode 100644 index 0000000..30f2745 --- /dev/null +++ b/src/test/java/home/gateway/adapters/solarman/ModbusRtuFrameTest.java @@ -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)); + } +} diff --git a/src/test/java/home/gateway/adapters/solarman/SolarmanV5TransportTest.java b/src/test/java/home/gateway/adapters/solarman/SolarmanV5TransportTest.java new file mode 100644 index 0000000..b6c8476 --- /dev/null +++ b/src/test/java/home/gateway/adapters/solarman/SolarmanV5TransportTest.java @@ -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 requests = new CopyOnWriteArrayList<>(); + volatile int connections; + private final ServerSocket server = new ServerSocket(0); + private final List accepted = new CopyOnWriteArrayList<>(); + private volatile Function responder = request -> null; + + FakeLogger() throws IOException { + Thread.ofVirtual().start(this::acceptLoop); + } + + int port() { + return server.getLocalPort(); + } + + void respondWith(Function 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(); + } + } + } +}