feat(deye): add DeyeDevice reading the hybrid inverter over Modbus
CI / Backend tests (Maven / Java 21) (push) Successful in 43s

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 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5.5 committed 2026-10-24 00:26:43 +02:00
1 parent b99414d829
commit 2b06a5f344
2 files changed
+271

No files matched your search

@@ -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<Block> 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<Field> 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<Reading> read(Instant now) throws IOException {
Map<Integer, Integer> words = readBlocks();
Map<String, Double> 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<Integer, Integer> readBlocks() throws IOException {
Map<Integer, Integer> 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;
}
}
@@ -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<Integer, Integer> registers = new HashMap<>();
final List<String> 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<String, Double> 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<String, Double> 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<Reading> readings = device.read(NOW);
assertEquals(1, readings.size());
return readings.get(0);
}
}