feat(deye): add DeyeDevice reading the hybrid inverter over Modbus
CI / Backend tests (Maven / Java 21) (push) Successful in 43s
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:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user