Merge PR #24: feat(meter): read the SDM630 house meter into electricity.PRAD2

This commit is contained in:
root committed 2026-10-24 00:33:12 +02:00
commit 3705a57155
4 files changed
+433

No files matched your search

@@ -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<Register> registers) {
}
private final ModbusClient modbus;
private final List<Block> 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<Reading> read(Instant now) throws IOException {
Map<String, Double> 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<String, Double> 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<Block> blocksOf(List<Register> registers) {
Map<Integer, List<Register>> byBatch = new TreeMap<>();
for (Register register : registers) {
byBatch.computeIfAbsent(register.batch(), batch -> new ArrayList<>()).add(register);
}
List<Block> result = new ArrayList<>();
for (List<Register> 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);
}
}
@@ -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<Register> 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() {
}
}
@@ -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<String, Double> lastPoint = lastPoint();
FakeMeter meter = new FakeMeter();
lastPoint.forEach((name, value) -> meter.set(name, value.floatValue()));
List<Reading> 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<String, Double> 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<String, Double> 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<String, Double> lastPoint() throws IOException {
try (InputStream in = HouseMeterDeviceTest.class.getResourceAsStream("prad2-last-point.json")) {
Map<String, Object> point = new ObjectMapper().readValue(in, new TypeReference<>() { });
Map<String, Double> 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<String> 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");
}
}
}
@@ -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
}