From 2b06a5f344362963d87f938c9737cb9627fac20e Mon Sep 17 00:00:00 2001 From: Mariusz Pacek Date: Sat, 24 Oct 2026 00:26:43 +0200 Subject: [PATCH] feat(deye): add DeyeDevice reading the hybrid inverter over Modbus Explicit small FC3 blocks, decoding per the verified register table (signed battery and grid power, battery temperature offset), one electricity reading per poll; any failed block fails the poll. Co-Authored-By: Claude Sonnet 5.5 --- .../gateway/adapters/deye/DeyeDevice.java | 107 ++++++++++++ .../gateway/adapters/deye/DeyeDeviceTest.java | 164 ++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 src/main/java/home/gateway/adapters/deye/DeyeDevice.java create mode 100644 src/test/java/home/gateway/adapters/deye/DeyeDeviceTest.java diff --git a/src/main/java/home/gateway/adapters/deye/DeyeDevice.java b/src/main/java/home/gateway/adapters/deye/DeyeDevice.java new file mode 100644 index 0000000..de0418a --- /dev/null +++ b/src/main/java/home/gateway/adapters/deye/DeyeDevice.java @@ -0,0 +1,107 @@ +package home.gateway.adapters.deye; + +import home.gateway.adapters.modbus.ModbusClient; +import home.gateway.application.Device; +import home.gateway.domain.Reading; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.DoubleUnaryOperator; + +/** The Deye hybrid inverter; registers and scales are verified in research/energy/README.md. */ +public final class DeyeDevice implements Device { + + private static final int UNIT_ID = 1; + + private record Block(int address, int count) { + } + + private record Field(String name, int register, DoubleUnaryOperator decode) { + } + + /** One request per block, in this order; the serial (3–7) is identity only and is skipped. */ + private static final List BLOCKS = List.of( + new Block(514, 2), new Block(520, 2), new Block(526, 1), new Block(529, 1), + new Block(586, 3), new Block(590, 1), new Block(598, 3), new Block(622, 4), + new Block(650, 4), new Block(672, 2)); + + private static final List FIELDS = List.of( + new Field("battery_charge_today_kwh", 514, scaled(10)), + new Field("battery_discharge_today_kwh", 515, scaled(10)), + new Field("grid_bought_today_kwh", 520, scaled(10)), + new Field("grid_sold_today_kwh", 521, scaled(10)), + new Field("load_today_kwh", 526, scaled(10)), + new Field("pv_today_kwh", 529, scaled(10)), + new Field("battery_temperature_c", 586, x -> (x - 1000) / 10), + new Field("battery_voltage_v", 587, scaled(100)), + new Field("battery_soc_pct", 588, scaled(1)), + new Field("battery_power_w", 590, DeyeDevice::signed), + new Field("grid_voltage_l1_v", 598, scaled(10)), + new Field("grid_voltage_l2_v", 599, scaled(10)), + new Field("grid_voltage_l3_v", 600, scaled(10)), + new Field("grid_power_l1_w", 622, DeyeDevice::signed), + new Field("grid_power_l2_w", 623, DeyeDevice::signed), + new Field("grid_power_l3_w", 624, DeyeDevice::signed), + new Field("grid_power_w", 625, DeyeDevice::signed), + new Field("load_power_l1_w", 650, scaled(1)), + new Field("load_power_l2_w", 651, scaled(1)), + new Field("load_power_l3_w", 652, scaled(1)), + new Field("load_power_w", 653, scaled(1)), + new Field("pv1_power_w", 672, scaled(1)), + new Field("pv2_power_w", 673, scaled(1))); + + private final ModbusClient modbus; + + public DeyeDevice(ModbusClient modbus) { + this.modbus = modbus; + } + + @Override + public String name() { + return "deye"; + } + + @Override + public Duration interval() { + return Duration.ofSeconds(60); + } + + @Override + public List read(Instant now) throws IOException { + Map words = readBlocks(); + Map fields = new LinkedHashMap<>(); + for (Field field : FIELDS) { + fields.put(field.name(), field.decode().applyAsDouble(words.get(field.register()))); + } + return List.of(new Reading("electricity", "deye", Map.of("location", "dom-szczepancowa"), + fields, now)); + } + + private Map readBlocks() throws IOException { + Map words = new HashMap<>(); + for (Block block : BLOCKS) { + int[] values = modbus.readHoldingRegisters(UNIT_ID, block.address(), block.count()); + if (values.length != block.count()) { + throw new IOException("Deye block " + block.address() + " returned " + + values.length + " registers, expected " + block.count()); + } + for (int i = 0; i < values.length; i++) { + words.put(block.address() + i, values[i]); + } + } + return words; + } + + private static DoubleUnaryOperator scaled(int divisor) { + return x -> x / divisor; + } + + private static double signed(double word) { + return (short) (int) word; + } +} diff --git a/src/test/java/home/gateway/adapters/deye/DeyeDeviceTest.java b/src/test/java/home/gateway/adapters/deye/DeyeDeviceTest.java new file mode 100644 index 0000000..5b223a5 --- /dev/null +++ b/src/test/java/home/gateway/adapters/deye/DeyeDeviceTest.java @@ -0,0 +1,164 @@ +package home.gateway.adapters.deye; + +import home.gateway.adapters.modbus.ModbusClient; +import home.gateway.domain.Reading; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DeyeDeviceTest { + + private static final Instant NOW = Instant.parse("2026-10-23T20:26:00Z"); + + /** Serves raw words by register and records every request. */ + private static class FakeInverter implements ModbusClient { + final Map registers = new HashMap<>(); + final List requests = new ArrayList<>(); + int failingAddress = -1; + + FakeInverter() { + registers.putAll(Map.of(514, 5, 515, 1, 520, 168, 521, 223, 526, 242, 529, 299)); + registers.putAll(Map.of(586, 1192, 587, 5649, 588, 99, 590, 65536 - 24)); + registers.putAll(Map.of(598, 2339, 599, 2334, 600, 2319)); + registers.putAll(Map.of(622, 390, 623, 52, 624, 203, 625, 645)); + registers.putAll(Map.of(650, 364, 651, 26, 652, 180, 653, 570)); + registers.putAll(Map.of(672, 0, 673, 0)); + } + + @Override + public int[] readHoldingRegisters(int unitId, int address, int count) throws IOException { + requests.add(unitId + ":" + address + "+" + count); + if (address == failingAddress) { + throw new IOException("no answer"); + } + int[] words = new int[count]; + for (int i = 0; i < count; i++) { + words[i] = registers.get(address + i); + } + return words; + } + + @Override + public int[] readInputRegisters(int unitId, int address, int count) { + throw new AssertionError("the Deye is read with FC3 only"); + } + } + + private final FakeInverter inverter = new FakeInverter(); + private final DeyeDevice device = new DeyeDevice(inverter); + + @Test + void namesItselfAndPollsEveryMinute() { + assertEquals("deye", device.name()); + assertEquals(Duration.ofSeconds(60), device.interval()); + } + + @Test + void decodesTheValuesVerifiedAtCheckTime() throws IOException { + Map fields = onlyReading().fields(); + + assertEquals(19.2, fields.get("battery_temperature_c"), 1e-9); + assertEquals(56.49, fields.get("battery_voltage_v"), 1e-9); + assertEquals(99.0, fields.get("battery_soc_pct")); + assertEquals(-24.0, fields.get("battery_power_w")); + assertEquals(233.9, fields.get("grid_voltage_l1_v"), 1e-9); + assertEquals(233.4, fields.get("grid_voltage_l2_v"), 1e-9); + assertEquals(231.9, fields.get("grid_voltage_l3_v"), 1e-9); + assertEquals(390.0, fields.get("grid_power_l1_w")); + assertEquals(52.0, fields.get("grid_power_l2_w")); + assertEquals(203.0, fields.get("grid_power_l3_w")); + assertEquals(645.0, fields.get("grid_power_w")); + assertEquals(364.0, fields.get("load_power_l1_w")); + assertEquals(26.0, fields.get("load_power_l2_w")); + assertEquals(180.0, fields.get("load_power_l3_w")); + assertEquals(570.0, fields.get("load_power_w")); + assertEquals(0.0, fields.get("pv1_power_w")); + assertEquals(0.0, fields.get("pv2_power_w")); + assertEquals(0.5, fields.get("battery_charge_today_kwh"), 1e-9); + assertEquals(0.1, fields.get("battery_discharge_today_kwh"), 1e-9); + assertEquals(16.8, fields.get("grid_bought_today_kwh"), 1e-9); + assertEquals(22.3, fields.get("grid_sold_today_kwh"), 1e-9); + assertEquals(24.2, fields.get("load_today_kwh"), 1e-9); + assertEquals(29.9, fields.get("pv_today_kwh"), 1e-9); + assertEquals(23, fields.size()); + } + + @Test + void labelsTheReading() throws IOException { + Reading reading = onlyReading(); + + assertEquals("electricity", reading.database()); + assertEquals("deye", reading.measurement()); + assertEquals(Map.of("location", "dom-szczepancowa"), reading.tags()); + assertEquals(NOW, reading.time()); + } + + @Test + void decodesExportingGridPowerAsNegative() throws IOException { + inverter.registers.putAll(Map.of(622, 65536 - 1500, 625, 65536 - 1200)); + + Map fields = onlyReading().fields(); + + assertEquals(-1500.0, fields.get("grid_power_l1_w")); + assertEquals(-1200.0, fields.get("grid_power_w")); + } + + @Test + void decodesDischargingBatteryAsPositiveAndChargingAsNegative() throws IOException { + inverter.registers.put(590, 3000); + assertEquals(3000.0, onlyReading().fields().get("battery_power_w")); + + inverter.registers.put(590, 65536 - 3000); + assertEquals(-3000.0, onlyReading().fields().get("battery_power_w")); + } + + @Test + void decodesFreezingBatteryTemperature() throws IOException { + inverter.registers.put(586, 950); + + assertEquals(-5.0, onlyReading().fields().get("battery_temperature_c"), 1e-9); + } + + @Test + void readsSmallBlocksInOrderWithoutTheSerial() throws IOException { + onlyReading(); + + assertEquals(List.of("1:514+2", "1:520+2", "1:526+1", "1:529+1", "1:586+3", "1:590+1", + "1:598+3", "1:622+4", "1:650+4", "1:672+2"), inverter.requests); + } + + @Test + void failsWhenAnyBlockFailsAndStopsThere() { + inverter.failingAddress = 598; + + assertThrows(IOException.class, () -> device.read(NOW)); + assertEquals("1:598+3", inverter.requests.get(inverter.requests.size() - 1)); + } + + @Test + void failsOnAShortAnswer() { + DeyeDevice shortAnswers = new DeyeDevice(new FakeInverter() { + @Override + public int[] readHoldingRegisters(int unitId, int address, int count) { + return new int[count - 1]; + } + }); + + assertThrows(IOException.class, () -> shortAnswers.read(NOW)); + } + + private Reading onlyReading() throws IOException { + List readings = device.read(NOW); + assertEquals(1, readings.size()); + return readings.get(0); + } +}