diff --git a/src/main/java/home/gateway/adapters/meter/HouseMeterDevice.java b/src/main/java/home/gateway/adapters/meter/HouseMeterDevice.java new file mode 100644 index 0000000..1d35879 --- /dev/null +++ b/src/main/java/home/gateway/adapters/meter/HouseMeterDevice.java @@ -0,0 +1,99 @@ +package home.gateway.adapters.meter; + +import home.gateway.adapters.meter.Sdm630Registers.Register; +import home.gateway.adapters.modbus.ModbusClient; +import home.gateway.application.Device; +import home.gateway.domain.Reading; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** The Eastron SDM630 house meter: all input registers, one point per poll. */ +public final class HouseMeterDevice implements Device { + + private static final int UNIT_ID = 1; + private static final int MAX_REGISTERS_PER_REQUEST = 125; + private static final Duration INTERVAL = Duration.ofSeconds(60); + private static final int DECIMALS = 2; + + private record Block(int address, int count, List registers) { + } + + private final ModbusClient modbus; + private final List blocks = blocksOf(Sdm630Registers.INPUT); + + public HouseMeterDevice(ModbusClient modbus) { + this.modbus = modbus; + } + + @Override + public String name() { + return "house-meter"; + } + + @Override + public Duration interval() { + return INTERVAL; + } + + @Override + public List read(Instant now) throws IOException { + Map fields = new LinkedHashMap<>(); + for (Block block : blocks) { + readBlock(block, fields); + } + if (fields.isEmpty()) { + throw new IOException("house meter returned no finite value"); + } + return List.of(new Reading("electricity", "PRAD2", Map.of("location", "dom-szczepancowa"), + fields, now)); + } + + private void readBlock(Block block, Map fields) throws IOException { + int[] words = modbus.readInputRegisters(UNIT_ID, block.address(), block.count()); + if (words.length != block.count()) { + throw new IOException("house meter block at " + block.address() + " returned " + + words.length + " registers, expected " + block.count()); + } + for (Register register : block.registers()) { + int offset = register.address() - block.address(); + float value = Float.intBitsToFloat((words[offset] << 16) | words[offset + 1]); + // The old Python code lost the whole point on NaN or infinity; one bad value + // should not cost the other 70. + if (Float.isFinite(value)) { + fields.put(register.name(), round(value)); + } + } + } + + /** Same as Python's {@code round(float(x), 2)}: the exact binary value, ties to even. */ + private static double round(float value) { + return new BigDecimal(value).setScale(DECIMALS, RoundingMode.HALF_EVEN).doubleValue(); + } + + private static List blocksOf(List registers) { + Map> byBatch = new TreeMap<>(); + for (Register register : registers) { + byBatch.computeIfAbsent(register.batch(), batch -> new ArrayList<>()).add(register); + } + List result = new ArrayList<>(); + for (List batch : byBatch.values()) { + int first = batch.getFirst().address(); + int count = batch.getLast().address() + Sdm630Registers.WORDS_PER_VALUE - first; + if (count > MAX_REGISTERS_PER_REQUEST) { + throw new IllegalStateException("block at " + first + " has " + count + + " registers, limit " + MAX_REGISTERS_PER_REQUEST); + } + result.add(new Block(first, count, batch)); + } + return List.copyOf(result); + } +} diff --git a/src/main/java/home/gateway/adapters/meter/Sdm630Registers.java b/src/main/java/home/gateway/adapters/meter/Sdm630Registers.java new file mode 100644 index 0000000..0053fa3 --- /dev/null +++ b/src/main/java/home/gateway/adapters/meter/Sdm630Registers.java @@ -0,0 +1,93 @@ +package home.gateway.adapters.meter; + +import java.util.List; + +/** + * The SDM630 input registers, as sdm_modbus 0.4.4 reads them in {@code read_all()}. Names are the + * field names of {@code electricity.PRAD2}. Every value is a float32 in two registers, unscaled. + * The batch number is the library's: registers of one batch are fetched with one request. + */ +final class Sdm630Registers { + + static final int WORDS_PER_VALUE = 2; + + record Register(String name, int address, int batch) { + } + + static final List INPUT = List.of( + new Register("p1_voltage", 0x0000, 1), + new Register("p2_voltage", 0x0002, 1), + new Register("p3_voltage", 0x0004, 1), + new Register("p1_current", 0x0006, 1), + new Register("p2_current", 0x0008, 1), + new Register("p3_current", 0x000a, 1), + new Register("p1_power_active", 0x000c, 1), + new Register("p2_power_active", 0x000e, 1), + new Register("p3_power_active", 0x0010, 1), + new Register("p1_power_apparent", 0x0012, 1), + new Register("p2_power_apparent", 0x0014, 1), + new Register("p3_power_apparent", 0x0016, 1), + new Register("p1_power_reactive", 0x0018, 1), + new Register("p2_power_reactive", 0x001a, 1), + new Register("p3_power_reactive", 0x001c, 1), + new Register("p1_power_factor", 0x001e, 1), + new Register("p2_power_factor", 0x0020, 1), + new Register("p3_power_factor", 0x0022, 1), + new Register("p1_phase_angle", 0x0024, 1), + new Register("p2_phase_angle", 0x0026, 1), + new Register("p3_phase_angle", 0x0028, 1), + new Register("voltage_ln", 0x002a, 1), + new Register("current_ln", 0x002e, 1), + new Register("total_line_current", 0x0030, 1), + new Register("total_power_active", 0x0034, 1), + new Register("total_power_apparent", 0x0038, 1), + new Register("total_power_reactive", 0x003c, 1), + new Register("total_power_factor", 0x003e, 1), + new Register("total_phase_angle", 0x0042, 1), + new Register("frequency", 0x0046, 1), + new Register("import_energy_active", 0x0048, 1), + new Register("export_energy_active", 0x004a, 1), + new Register("import_energy_reactive", 0x004c, 1), + new Register("export_energy_reactive", 0x004e, 1), + new Register("total_energy_apparent", 0x0050, 2), + new Register("total_current", 0x0052, 2), + new Register("total_import_demand_power_active", 0x0054, 2), + new Register("maximum_import_demand_power_apparent", 0x0056, 2), + new Register("import_demand_power_active", 0x0058, 2), + new Register("maximum_import_demand_power_active", 0x005a, 2), + new Register("export_demand_power_active", 0x005c, 2), + new Register("maximum_export_demand_power_active", 0x005e, 2), + new Register("total_demand_power_apparent", 0x0064, 2), + new Register("maximum_demand_power_apparent", 0x0066, 2), + new Register("neutral_demand_current", 0x0068, 2), + new Register("maximum_neutral_demand_current", 0x006a, 2), + new Register("p12_voltage", 0x00c8, 3), + new Register("p23_voltage", 0x00ca, 3), + new Register("p31_voltage", 0x00cc, 3), + new Register("voltage_ll", 0x00ce, 3), + new Register("neutral_current", 0x00e0, 3), + new Register("p1n_voltage_thd", 0x00ea, 3), + new Register("p2n_voltage_thd", 0x00ec, 3), + new Register("p3n_voltage_thd", 0x00ee, 3), + new Register("p1_current_thd", 0x00f0, 3), + new Register("p2_current_thd", 0x00f2, 3), + new Register("p3_current_thd", 0x00f4, 3), + new Register("voltage_ln_thd", 0x00f8, 3), + new Register("current_thd", 0x00fa, 3), + new Register("p1_demand_current", 0x0102, 3), + new Register("p2_demand_current", 0x0104, 3), + new Register("p3_demand_current", 0x0106, 3), + new Register("maximum_p1_demand_current", 0x0108, 3), + new Register("maximum_p2_demand_current", 0x010a, 3), + new Register("maximum_p3_demand_current", 0x010c, 3), + new Register("p12_voltage_thd", 0x014e, 4), + new Register("p23_voltage_thd", 0x0150, 4), + new Register("p31_voltage_thd", 0x0152, 4), + new Register("voltage_ll_thd", 0x0154, 4), + new Register("total_energy_active", 0x0156, 4), + new Register("total_energy_reactive", 0x0158, 4) + ); + + private Sdm630Registers() { + } +} diff --git a/src/test/java/home/gateway/adapters/meter/HouseMeterDeviceTest.java b/src/test/java/home/gateway/adapters/meter/HouseMeterDeviceTest.java new file mode 100644 index 0000000..cb6c7dc --- /dev/null +++ b/src/test/java/home/gateway/adapters/meter/HouseMeterDeviceTest.java @@ -0,0 +1,166 @@ +package home.gateway.adapters.meter; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import home.gateway.adapters.modbus.ModbusClient; +import home.gateway.domain.Reading; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +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 java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class HouseMeterDeviceTest { + + private static final Instant NOW = Instant.parse("2026-10-24T10:00:00Z"); + + @Test + void reportsOneReadingWithEveryPrad2FieldRoundedLikeTheOldService() throws Exception { + Map lastPoint = lastPoint(); + FakeMeter meter = new FakeMeter(); + lastPoint.forEach((name, value) -> meter.set(name, value.floatValue())); + + List readings = new HouseMeterDevice(meter).read(NOW); + + assertEquals(1, readings.size()); + Reading reading = readings.getFirst(); + assertEquals("electricity", reading.database()); + assertEquals("PRAD2", reading.measurement()); + assertEquals(Map.of("location", "dom-szczepancowa"), reading.tags()); + assertEquals(NOW, reading.time()); + assertEquals(lastPoint.keySet(), reading.fields().keySet()); + lastPoint.forEach((name, expected) -> + assertEquals(expected, reading.fields().get(name), 0.01, name)); + } + + @Test + void readsFourBlocksOfUnitOneOneAfterAnother() throws Exception { + FakeMeter meter = new FakeMeter(); + meter.set("frequency", 50f); + + new HouseMeterDevice(meter).read(NOW); + + assertEquals(List.of("1:0+80", "1:80+28", "1:200+70", "1:334+12"), meter.requests); + } + + @Test + void roundsHalfToEvenOnTheExactValue() throws Exception { + FakeMeter meter = new FakeMeter(); + meter.set("p1_voltage", 0.125f); + meter.set("p2_voltage", 2.675f); + meter.set("p3_voltage", -0.375f); + + Map fields = new HouseMeterDevice(meter).read(NOW).getFirst().fields(); + + assertEquals(0.12, fields.get("p1_voltage")); + assertEquals(2.67, fields.get("p2_voltage")); + assertEquals(-0.38, fields.get("p3_voltage")); + } + + @Test + void skipsNanAndInfiniteValuesButKeepsTheRest() throws Exception { + FakeMeter meter = new FakeMeter(); + meter.set("frequency", 50f); + meter.set("p1_voltage", Float.NaN); + meter.set("p2_voltage", Float.POSITIVE_INFINITY); + + Map fields = new HouseMeterDevice(meter).read(NOW).getFirst().fields(); + + assertEquals(50.0, fields.get("frequency")); + assertFalse(fields.containsKey("p1_voltage")); + assertFalse(fields.containsKey("p2_voltage")); + } + + @Test + void failsWhenNoValueIsFinite() { + FakeMeter meter = new FakeMeter(); + meter.fillWith(Float.NaN); + + assertThrows(IOException.class, () -> new HouseMeterDevice(meter).read(NOW)); + } + + @Test + void failsWhenAnyBlockFails() { + FakeMeter meter = new FakeMeter(); + meter.failAt = 200; + + assertThrows(IOException.class, () -> new HouseMeterDevice(meter).read(NOW)); + } + + @Test + void failsWhenABlockComesBackShort() { + FakeMeter meter = new FakeMeter(); + meter.shortAt = 80; + + assertThrows(IOException.class, () -> new HouseMeterDevice(meter).read(NOW)); + } + + @Test + void isNamedAndPolledEveryMinute() { + HouseMeterDevice device = new HouseMeterDevice(new FakeMeter()); + + assertEquals("house-meter", device.name()); + assertEquals(Duration.ofSeconds(60), device.interval()); + } + + private static Map lastPoint() throws IOException { + try (InputStream in = HouseMeterDeviceTest.class.getResourceAsStream("prad2-last-point.json")) { + Map point = new ObjectMapper().readValue(in, new TypeReference<>() { }); + Map fields = new HashMap<>(); + point.forEach((name, value) -> { + if (!Set.of("time", "location").contains(name)) { + fields.put(name, ((Number) value).doubleValue()); + } + }); + return fields; + } + } + + /** A meter whose input registers hold float32 values, big-endian words; unset ones are 0. */ + private static final class FakeMeter implements ModbusClient { + + final int[] registers = new int[400]; + final List requests = new ArrayList<>(); + int failAt = -1; + int shortAt = -1; + + void set(String name, float value) { + Sdm630Registers.Register register = Sdm630Registers.INPUT.stream() + .filter(r -> r.name().equals(name)).findFirst().orElseThrow(); + int bits = Float.floatToRawIntBits(value); + registers[register.address()] = bits >>> 16; + registers[register.address() + 1] = bits & 0xFFFF; + } + + void fillWith(float value) { + Sdm630Registers.INPUT.forEach(register -> set(register.name(), value)); + } + + @Override + public int[] readInputRegisters(int unitId, int address, int count) throws IOException { + requests.add(unitId + ":" + address + "+" + count); + if (address == failAt) { + throw new IOException("timeout"); + } + int returned = address == shortAt ? count - 1 : count; + int[] words = new int[returned]; + System.arraycopy(registers, address, words, 0, returned); + return words; + } + + @Override + public int[] readHoldingRegisters(int unitId, int address, int count) { + throw new UnsupportedOperationException("the meter is read through input registers"); + } + } +} diff --git a/src/test/resources/home/gateway/adapters/meter/prad2-last-point.json b/src/test/resources/home/gateway/adapters/meter/prad2-last-point.json new file mode 100644 index 0000000..c02f56e --- /dev/null +++ b/src/test/resources/home/gateway/adapters/meter/prad2-last-point.json @@ -0,0 +1,75 @@ +{ + "time": "2026-10-23T22:13:40Z", + "current_ln": 0.85, + "current_thd": 95.36, + "export_demand_power_active": 0, + "export_energy_active": 66677.11, + "export_energy_reactive": 26926.61, + "frequency": 50.01, + "import_demand_power_active": 0, + "import_energy_active": 67514.27, + "import_energy_reactive": 6433.02, + "location": "dom-szczepancowa", + "maximum_demand_power_apparent": 10989.65, + "maximum_export_demand_power_active": 0, + "maximum_import_demand_power_active": 0, + "maximum_import_demand_power_apparent": -10986.21, + "maximum_neutral_demand_current": 20.28, + "maximum_p1_demand_current": 21.31, + "maximum_p2_demand_current": 20.79, + "maximum_p3_demand_current": 16.35, + "neutral_current": 0.67, + "neutral_demand_current": 0.77, + "p12_voltage": 408.07, + "p12_voltage_thd": 0, + "p1_current": 0.32, + "p1_current_thd": 114.36, + "p1_demand_current": 0.33, + "p1_phase_angle": 41.52, + "p1_power_active": 55.94, + "p1_power_apparent": 74.71, + "p1_power_factor": 0.75, + "p1_power_reactive": 49.52, + "p1_voltage": 235.69, + "p1n_voltage_thd": 3.48, + "p23_voltage": 405.05, + "p23_voltage_thd": 0, + "p2_current": 0.88, + "p2_current_thd": 99.27, + "p2_demand_current": 0.98, + "p2_phase_angle": -30.11, + "p2_power_active": 179.47, + "p2_power_apparent": 207.46, + "p2_power_factor": 0.87, + "p2_power_reactive": -104.07, + "p2_voltage": 235.51, + "p2n_voltage_thd": 3.82, + "p31_voltage": 405.21, + "p31_voltage_thd": 0, + "p3_current": 0.67, + "p3_current_thd": 72.45, + "p3_demand_current": 0.86, + "p3_phase_angle": 9.81, + "p3_power_active": 153.38, + "p3_power_apparent": 155.65, + "p3_power_factor": 0.99, + "p3_power_reactive": 26.52, + "p3_voltage": 232.2, + "p3n_voltage_thd": 3.64, + "total_current": 262147.94, + "total_demand_power_apparent": 507.11, + "total_energy_active": 134191.39, + "total_energy_apparent": 138275.8, + "total_energy_reactive": 33359.63, + "total_import_demand_power_active": 452.07, + "total_line_current": 2.55, + "total_phase_angle": -4.12, + "total_power_active": 388.78, + "total_power_apparent": 437.82, + "total_power_factor": 1, + "total_power_reactive": -28.04, + "voltage_ll": 406.11, + "voltage_ll_thd": 0, + "voltage_ln": 234.47, + "voltage_ln_thd": 3.65 +} \ No newline at end of file