Compare commits

..
26 Commits
Author SHA1 Message Date
root 9e997e43f3 Merge PR #73: feat: show the deployed version of each part in Stan
CI / Check (gateway, backend, frontend) (push) Successful in 1m12s
Release / Images (push) Successful in 2m54s
2026-10-24 12:55:37 +02:00
root 8ae6cea536 Merge master into feat/show-versions
CI / Check (gateway, backend, frontend) (push) Successful in 1m16s
# Conflicts:
#	backend/src/main/java/home/backend/Backend.java
#	backend/src/test/java/home/backend/adapters/http/ApiServerTest.java
#	gateway/README.md
2026-10-24 12:53:16 +02:00
root 604ef4e6d9 Merge PR #71: feat(energy): device details panels for Deye, Fronius and the house meter
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
2026-10-24 12:50:10 +02:00
rootandClaude Opus 5.5 b0735471b4 feat(status): show deployed versions of app, server and gateway
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
Each image gets the release tag as the VERSION build arg. The gateway
reports it in /health, the backend adds its own and the gateway's to
/api/health, and the Stan screen lists all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:50:03 +02:00
rootandClaude Opus 5.5 6f50f5b7b9 feat(energy): show Deye totals, inverter and per-phase meter energy
CI / Check (gateway, backend, frontend) (push) Successful in 1m16s
The gateway now reads the Deye lifetime counters, inverter power and
temperatures, and the meter's per-phase energy into house_meter_phases.
A group can take its fields from another measurement of the same device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:47:55 +02:00
root bad67b9538 Merge PR #72: feat(commands): setpoint changes are leases the backend renews 2026-10-24 12:46:08 +02:00
root ce00d0e81b Merge PR #70: feat(deye): read totals, PV voltage and current, temperatures, frequency 2026-10-24 12:46:08 +02:00
root 0eebba072e Merge PR #69: feat(meter): write per-phase energy counters as house_meter_phases 2026-10-24 12:46:08 +02:00
rootandClaude Opus 5.5 ea83255b91 perf(deye): read the registers in ten blocks as before
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
The new fields added seven requests per poll over the Solarman logger.
Neighbouring registers now share one request, so each poll again makes
ten round trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:41:56 +02:00
rootandClaude Opus 5.5 ba8631b3c2 docs: ADR-011 setpoint leases, spec F3 limits decided, roadmap M4
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:40:36 +02:00
rootandClaude Opus 5.5 6cae491b1c feat(web): show a change without end time as lasting while the server runs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:40:36 +02:00
rootandClaude Opus 5.5 83333a19bf docs(meter): wrap the register list comment
CI / Check (gateway, backend, frontend) (push) Successful in 1m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:39:45 +02:00
rootandClaude Opus 5.5 0072017966 feat(backend): renew the gateway's setpoint leases every 5 minutes
Without renewals the gateway restores every change after 15 minutes, so a
lost backend or tunnel never leaves the house on boost or away settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:39:10 +02:00
rootandClaude Opus 5.5 35b3f8e1e0 feat(web): open device details from the energy screen
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
A "Urządzenia" card lists the Deye, Fronius and house meter as rows with
a "Szczegóły ›" chevron, 56 px high. Tapping one opens a bottom sheet on
a phone (a dialog on wide screens) with the grouped values; it closes by
button, backdrop or Escape and returns focus to the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:38:45 +02:00
rootandClaude Opus 5.5 7a17bbbcdb feat(energy): serve device details at /api/energy/details
Groups of labelled values for the Deye, Fronius and house meter, so the
energy screen can show more without crowding it. A stale device has no
groups; a field the device did not report is left out.

The catalog of labels, units and groups lives in the backend
(EnergyDetailsService): the frontend renders whatever groups arrive, so
a new field is one line in one file and needs no UI release. It also
keeps field names out of TypeScript and lets the backend round values
(Wh to kWh) once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:38:45 +02:00
rootandClaude Opus 5.5 f0c974c82a feat(deye): read totals, PV voltage and current, temperatures, frequency
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
Verified live on 2026-10-24 (read only, two dumps a minute apart): totals
are 32-bit low word first and stay above the today counters, PV V x A
matches PV W within the 0.1 A resolution, per-phase inverter power sums
to 636, battery current agrees with battery power, frequency is 49.95 Hz.
Rejected as unclear: alarm words 552-558, inverter and load currents
(scale does not fit), registers 592 and 604-607.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:37:33 +02:00
rootandClaude Opus 5.5 d557f98df6 feat(gateway): restore every change when its lease lapses, renew over HTTP
A change now keeps a restore that runs at the earlier of its end time and a
15 minute lease; POST /commands/renew extends live leases. A lost backend or
tunnel can no longer leave boost or away settings on the controller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:37:25 +02:00
rootandClaude Opus 5.5 9f33f5a94c feat(meter): write per-phase energy counters as house_meter_phases
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
One extra FC4 request per poll; PRAD2 is unchanged and still written when
the extra request fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:36:22 +02:00
root 798f98b69d Merge PR #68: docs(roadmap): meter and BME280 run in the gateway since v1.2.1
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
2026-10-24 12:21:13 +02:00
rootandClaude Opus 5.5 e65cb5837f docs(roadmap): meter and BME280 run in the gateway since v1.2.1
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:18:46 +02:00
root 0b3197ac55 Merge PR #67: fix(meter): speak RTU over TCP to the house meter's converter
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
Release / Images (push) Successful in 2m51s
2026-10-24 12:09:59 +02:00
rootandClaude Opus 5.5 5513c09c51 fix(meter): speak RTU over TCP to the house meter's converter
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
The RS485 converter at 192.168.0.10 forwards raw RTU frames (unit, PDU,
CRC-16), as sdm_modbus does with ModbusRtuFramer. The gateway sent Modbus
TCP with an MBAP header, which the converter never answers, so every poll
timed out. TcpTransport now takes a framing: MBAP for MK01, RTU for the
meter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:07:19 +02:00
root 697b68ec39 Merge PR #66: feat(web): name the house Szczepańcowa instead of Krosno
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
Release / Images (push) Successful in 2m51s
2026-10-24 11:29:22 +02:00
root 645bbc4b88 Merge PR #65: docs(roadmap): the new gateway runs on the Pi with the inverters; D7 fixed 2026-10-24 11:29:22 +02:00
rootandClaude Opus 5.5 2d7899ebc8 feat(web): name the house Szczepańcowa instead of Krosno
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:26:55 +02:00
rootandClaude Opus 5.5 c34a9da0d7 docs(roadmap): the new gateway runs on the Pi with the inverters; D7 fixed
CI / Check (gateway, backend, frontend) (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:25:28 +02:00
74 changed files with 1816 additions and 202 deletions

No files matched your search

+3
View File
@@ -37,6 +37,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: gateway
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/arm64,linux/amd64
push: true
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
@@ -44,6 +45,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: backend
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/amd64
push: true
tags: ${{ env.IMAGES }}-backend:${{ gitea.ref_name }},${{ env.IMAGES }}-backend:latest
@@ -51,6 +53,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: frontend
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/amd64
push: true
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
+3
View File
@@ -8,6 +8,9 @@ COPY src src
RUN mvn -B -q package -Dmaven.test.skip=true
FROM eclipse-temurin:21-jre-alpine
# The release tag, shown in /api/health; the release workflow passes it.
ARG VERSION=dev
ENV VERSION=$VERSION
USER 10001:10001
WORKDIR /app
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
+4
View File
@@ -3,6 +3,7 @@
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
without the two URLs.
@@ -28,3 +29,6 @@ docker compose -f backend/compose.yaml up --build # with backend/.e
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
rules (ADR-009).
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
@@ -6,14 +6,17 @@ import home.backend.adapters.gateway.GatewayHttpCommands;
import home.backend.adapters.http.ApiServer;
import home.backend.adapters.influx.InfluxHistoryRepository;
import home.backend.adapters.influx.InfluxSettings;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.LeaseRenewer;
import home.backend.application.SetpointService;
import java.io.IOException;
import java.time.Clock;
import java.util.Objects;
/** Wires the adapters and services together; the only place that knows all of them. */
final class Backend {
@@ -29,13 +32,21 @@ final class Backend {
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
var commands = new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(),
settings.gatewayTimeout());
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
var setpoints = new SetpointService(commands(settings), PresetFile.load(settings.presetsFile()));
return new ApiServer(settings.port(),
new HeatPumpService(gateway, clock),
new EnergyService(gateway, clock),
new HealthService(gateway, clock),
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
new HistoryService(influx, clock), setpoints);
}
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
static LeaseRenewer leaseRenewer(Settings settings) {
return new LeaseRenewer(commands(settings));
}
private static GatewayHttpCommands commands(Settings settings) {
return new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(), settings.gatewayTimeout());
}
}
+5 -1
View File
@@ -21,10 +21,14 @@ public final class Main {
System.exit(2);
return;
}
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
if (settings.gatewayToken() == null) {
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
} else {
var renewer = Backend.leaseRenewer(settings);
Runtime.getRuntime().addShutdownHook(new Thread(renewer::close));
renewer.start();
}
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
server.start();
System.out.println("home-pump-backend listening on port " + server.port());
}
@@ -23,7 +23,7 @@ import java.util.stream.Collectors;
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
public class GatewayHttpClient implements GatewayClient {
private record Health(List<DeviceHealth> devices) {
private record Health(String version, List<DeviceHealth> devices) {
}
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
@@ -61,7 +61,7 @@ public class GatewayHttpClient implements GatewayClient {
device.lastSuccess(), device.lastError(),
measurements.getOrDefault(device.name(), List.of())))
.toList();
return new GatewaySnapshot(devices);
return new GatewaySnapshot(health.version(), devices);
}
private static List<Measurement> measurements(DeviceReadings device) {
@@ -27,11 +27,15 @@ public class GatewayHttpCommands implements GatewayCommands {
private record Request(String setpoint, int value, Long minutes) {
}
private record Renewal(int renewed) {
}
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI endpoint;
private final URI renewEndpoint;
private final String token;
private final Duration timeout;
private final HttpClient http;
@@ -39,6 +43,7 @@ public class GatewayHttpCommands implements GatewayCommands {
/** @param token null when not configured: every call then fails without touching the network */
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
this.endpoint = baseUri.resolve("/commands");
this.renewEndpoint = baseUri.resolve("/commands/renew");
this.token = token;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
@@ -59,11 +64,21 @@ public class GatewayHttpCommands implements GatewayCommands {
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
}
@Override
public int renewLeases() throws IOException {
var request = request(renewEndpoint).POST(HttpRequest.BodyPublishers.noBody());
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
}
private HttpRequest.Builder request() throws IOException {
return request(endpoint);
}
private HttpRequest.Builder request(URI uri) throws IOException {
if (token == null) {
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
}
return HttpRequest.newBuilder(endpoint).timeout(timeout).header("Authorization", "Bearer " + token);
return HttpRequest.newBuilder(uri).timeout(timeout).header("Authorization", "Bearer " + token);
}
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
@@ -9,6 +9,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
@@ -58,7 +59,8 @@ public class ApiServer {
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
/** Binds the port now; 0 picks a free one. */
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, HealthService health,
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints) throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(executor);
@@ -70,6 +72,7 @@ public class ApiServer {
});
api("/api/heat-pump", query -> heatPump.current());
api("/api/energy", query -> energy.current());
api("/api/energy/details", query -> energyDetails.current());
api("/api/health", query -> health.current());
route("/api/history", exchange -> handleHistory(exchange, history));
route("/api/setpoints", exchange -> handle(exchange, Map.of(
@@ -0,0 +1,157 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.EnergyDetails;
import home.backend.domain.EnergyDetails.Device;
import home.backend.domain.EnergyDetails.Group;
import home.backend.domain.EnergyDetails.Item;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
import java.time.Clock;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
/**
* F7 details: the Deye, Fronius and house meter fields worth a look, grouped and labelled. The
* catalog below is the only place that knows a field's label, unit and place; showing a new field
* is one line here.
*/
public class EnergyDetailsService {
/** A gateway field shown as {@code label}; {@code scale} turns the stored unit into {@code unit}. */
private record Field(String key, String label, String unit, int digits, double scale) {
}
private record Spec(String id, String name, String measurement, List<GroupSpec> groups) {
}
/** {@code measurement} is null for the device's own one. */
private record GroupSpec(String title, String measurement, List<Field> fields) {
}
// Each helper returns the fields it stands for, so a group can mix single fields and phase triples.
private static Field[] field(String key, String label, String unit, int digits) {
return new Field[] {new Field(key, label, unit, digits, 1)};
}
private static Field[] kwhFromWh(String key, String label, int digits) {
return new Field[] {new Field(key, label, "kWh", digits, 0.001)};
}
/** L1 to L3 of one quantity; {@code key} holds %d for the phase number. */
private static Field[] phases(String key, String label, String unit, int digits) {
return IntStream.rangeClosed(1, 3)
.mapToObj(n -> new Field(key.formatted(n), label + " L" + n, unit, digits, 1)).toArray(Field[]::new);
}
private static GroupSpec group(String title, Field[]... parts) {
return groupOf(null, title, parts);
}
/** A group whose fields come from another measurement of the same device. */
private static GroupSpec groupOf(String measurement, String title, Field[]... parts) {
return new GroupSpec(title, measurement, Arrays.stream(parts).flatMap(Arrays::stream).toList());
}
private static final List<Spec> CATALOG = List.of(
new Spec("deye", "Falownik Deye", "deye", List.of(
group("Bateria",
field("battery_soc_pct", "Naładowanie", "%", 0),
field("battery_power_w", "Moc", "W", 0),
field("battery_voltage_v", "Napięcie", "V", 1),
field("battery_current_a", "Prąd", "A", 1),
field("battery_temperature_c", "Temperatura", "°C", 1)),
group("Sieć",
field("grid_power_w", "Moc razem", "W", 0),
phases("grid_power_l%d_w", "Moc", "W", 0),
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
group("Odbiór domu",
field("load_power_w", "Moc razem", "W", 0),
phases("load_power_l%d_w", "Moc", "W", 0)),
group("Panele PV",
field("pv1_power_w", "PV1 moc", "W", 0),
field("pv1_voltage_v", "PV1 napięcie", "V", 1),
field("pv1_current_a", "PV1 prąd", "A", 1),
field("pv2_power_w", "PV2 moc", "W", 0),
field("pv2_voltage_v", "PV2 napięcie", "V", 1),
field("pv2_current_a", "PV2 prąd", "A", 1)),
group("Dziś",
field("pv_today_kwh", "Produkcja PV", "kWh", 1),
field("load_today_kwh", "Zużycie domu", "kWh", 1),
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
group("Od początku",
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
field("load_total_kwh", "Zużycie domu", "kWh", 0),
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
field("battery_discharge_total_kwh", "Bateria rozładowana", "kWh", 0)),
group("Falownik",
field("inverter_power_w", "Moc wyjściowa", "W", 0),
field("inverter_temperature_c", "Temperatura", "°C", 1),
field("heatsink_temperature_c", "Radiator", "°C", 1)))),
new Spec("fronius", "Falownik Fronius", "fronius", List.of(
group("Teraz", field("pv_power_w", "Moc PV", "W", 0)),
group("Produkcja",
kwhFromWh("energy_day_wh", "Dziś", 1),
kwhFromWh("energy_year_wh", "Ten rok", 0),
kwhFromWh("energy_total_wh", "Od początku", 0)))),
new Spec("house-meter", "Licznik domu", "PRAD2", List.of(
group("Moc czynna",
field("total_power_active", "Razem", "W", 0),
phases("p%d_power_active", "Moc", "W", 0)),
group("Napięcie", phases("p%d_voltage", "Napięcie", "V", 1)),
group("Prąd", phases("p%d_current", "Prąd", "A", 2)),
group("Współczynnik mocy", phases("p%d_power_factor", "cos φ", "", 2)),
group("Sieć", field("frequency", "Częstotliwość", "Hz", 2)),
group("Energia od początku",
field("import_energy_active", "Pobrana", "kWh", 0),
field("export_energy_active", "Oddana", "kWh", 0)),
groupOf("house_meter_phases", "Energia na fazę",
phases("l%d_import_energy_active_kwh", "Pobrana", "kWh", 0),
phases("l%d_export_energy_active_kwh", "Oddana", "kWh", 0)))));
private final GatewayClient gateway;
private final Clock clock;
public EnergyDetailsService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public EnergyDetails current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
return new EnergyDetails(CATALOG.stream().map(spec -> device(spec, snapshot)).toList());
}
private Device device(Spec spec, GatewaySnapshot snapshot) {
Live live = SnapshotFields.live(snapshot, spec.id(), spec.measurement(), clock.instant());
List<Group> groups = spec.groups().stream()
.map(g -> group(g, g.measurement() == null ? live.fields() : fieldsOf(snapshot, spec.id(), g)))
.filter(g -> !g.items().isEmpty()).toList();
return new Device(spec.id(), spec.name(), live.source().time(), live.source().stale(), groups);
}
private Map<String, Double> fieldsOf(GatewaySnapshot snapshot, String device, GroupSpec group) {
return SnapshotFields.live(snapshot, device, group.measurement(), clock.instant()).fields();
}
private static Group group(GroupSpec spec, Map<String, Double> fields) {
List<Item> items = spec.fields().stream().filter(f -> fields.containsKey(f.key()))
.map(f -> new Item(f.label(), scaled(fields.get(f.key()), f), f.unit(), f.digits())).toList();
return new Group(spec.title(), items);
}
/** Rounded to the shown precision, so a conversion leaves no binary noise such as 4.3999999. */
private static double scaled(double value, Field f) {
double factor = Math.pow(10, f.digits());
return Math.round(value * f.scale() * factor) / factor;
}
}
@@ -1,26 +1,21 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.EnergyView;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.EnergyView.Today;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
public class EnergyService {
/** One inverter's fields; empty when it is stale, so nothing old is shown as current. */
private record Inverter(Source source, Map<String, Double> fields) {
}
private final GatewayClient gateway;
private final Clock clock;
@@ -31,8 +26,8 @@ public class EnergyService {
public EnergyView current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
Inverter deye = inverter(snapshot, "deye");
Inverter fronius = inverter(snapshot, "fronius");
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
Map<String, Double> d = deye.fields();
Map<String, Double> f = fronius.fields();
@@ -46,13 +41,6 @@ public class EnergyService {
d.get("load_power_w"), today);
}
private Inverter inverter(GatewaySnapshot snapshot, String name) {
Optional<Measurement> reading = SnapshotFields.find(snapshot, name, name);
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, name), reading, clock.instant());
Source source = new Source(name, reading.map(Measurement::time).orElse(null), stale);
return new Inverter(source, stale ? Map.of() : reading.get().fields());
}
private static Instant oldest(List<Source> sources) {
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
}
@@ -13,4 +13,7 @@ public interface GatewayCommands {
CommandResult apply(Command command) throws IOException;
SetpointState setpoints() throws IOException;
/** Keeps the gateway's live restores alive; returns how many it extended. */
int renewLeases() throws IOException;
}
@@ -1,5 +1,6 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.HealthView;
import home.backend.domain.HealthView.DeviceHealth;
@@ -13,19 +14,21 @@ public class HealthService {
private final GatewayClient gateway;
private final Clock clock;
private final String version;
public HealthService(GatewayClient gateway, Clock clock) {
public HealthService(GatewayClient gateway, Clock clock, String version) {
this.gateway = gateway;
this.clock = clock;
this.version = version;
}
public HealthView current() {
try {
List<DeviceHealth> devices = gateway.snapshot().devices().stream()
.map(HealthService::health).toList();
return new HealthView(clock.instant(), true, devices);
GatewaySnapshot snapshot = gateway.snapshot();
List<DeviceHealth> devices = snapshot.devices().stream().map(HealthService::health).toList();
return new HealthView(clock.instant(), true, devices, version, snapshot.version());
} catch (IOException e) {
return new HealthView(clock.instant(), false, List.of());
return new HealthView(clock.instant(), false, List.of(), version, null);
}
}
@@ -0,0 +1,53 @@
package home.backend.application;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Tells the gateway now and then that we are still here (ADR-011): while it hears us, our setpoint
* changes stay; once we or the tunnel are gone for 15 minutes, it restores them on its own.
*/
public class LeaseRenewer implements AutoCloseable {
/** A third of the gateway's lease, so two renewals in a row may fail. */
public static final Duration PERIOD = Duration.ofMinutes(5);
private static final Logger LOGGER = LoggerFactory.getLogger(LeaseRenewer.class);
private final GatewayCommands gateway;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("lease-renewal").factory());
public LeaseRenewer(GatewayCommands gateway) {
this.gateway = gateway;
}
public void start() {
start(PERIOD);
}
void start(Duration period) {
scheduler.scheduleWithFixedDelay(this::renew, 0, period.toMillis(), TimeUnit.MILLISECONDS);
}
/** A failure is logged and retried next cycle; an exception would end the schedule for good. */
void renew() {
try {
int renewed = gateway.renewLeases();
LOGGER.debug("renewed {} lease(s)", renewed);
} catch (IOException | RuntimeException e) {
LOGGER.warn("could not renew the gateway leases, retrying in {}: {}", PERIOD, e.toString());
}
}
@Override
public void close() {
scheduler.shutdownNow();
}
}
@@ -1,5 +1,6 @@
package home.backend.application;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
@@ -29,6 +30,17 @@ final class SnapshotFields {
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 0;
}
/** One device's fields; empty when it is stale, so nothing old is shown as current. */
record Live(Source source, Map<String, Double> fields) {
}
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now) {
Optional<Measurement> reading = find(snapshot, device, measurement);
boolean stale = stale(device(snapshot, device), reading, now);
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
return new Live(source, stale ? Map.of() : reading.get().fields());
}
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
return snapshot.devices().stream()
.filter(d -> d.name().equals(device))
@@ -0,0 +1,22 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* Every value the energy devices report, grouped for a details panel (F7). A device without a
* fresh reading has {@code stale} true and no groups; a field the device did not report is absent.
*/
public record EnergyDetails(List<Device> devices) {
/** One device; {@code id} is the gateway's device name. */
public record Device(String id, String name, Instant time, boolean stale, List<Group> groups) {
}
public record Group(String title, List<Item> items) {
}
/** A value in {@code unit}, to be shown with {@code digits} decimals. */
public record Item(String label, double value, String unit, int digits) {
}
}
@@ -4,8 +4,9 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
/** The gateway's latest state of every device, as the backend sees it. */
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
/** The gateway's latest state of every device, as the backend sees it.
* The version is the gateway's release tag, null for a gateway that does not report it yet. */
public record GatewaySnapshot(String version, List<DeviceSnapshot> devices) {
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
List<Measurement> measurements) {
@@ -3,8 +3,10 @@ package home.backend.domain;
import java.time.Instant;
import java.util.List;
/** Health of the chain phone → backend → gateway → devices (F5). */
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
/** Health of the chain phone → backend → gateway → devices (F5), with the deployed versions.
* The gateway's version is null when it is unreachable or too old to report it. */
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices,
String backendVersion, String gatewayVersion) {
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
}
@@ -2,6 +2,9 @@ package home.backend.domain;
import java.time.Instant;
/** A boost the gateway will undo at {@code due}, unless someone changed the register meanwhile. */
/**
* A change the gateway will undo at {@code due} (null: until changed), unless someone changed the
* register meanwhile. It also undoes it when we stop renewing the lease (ADR-011).
*/
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
}
@@ -86,6 +86,20 @@ class GatewayHttpClientTest {
assertEquals(645.0, device(devices, "deye").measurements().get(0).fields().get("grid_power_w"));
}
@Test
void readsTheGatewayVersion() throws Exception {
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
assertEquals("v1.2.1", client.snapshot().version());
}
@Test
void anOldGatewayWithoutVersionHasNullVersion() throws Exception {
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
assertNull(client.snapshot().version());
}
@Test
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
@@ -32,7 +32,7 @@ class GatewayHttpCommandsTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Path CONTRACT = Path.of("../gateway/src/test/resources/commands");
private record Seen(String method, String authorization, String requestedBy, String body) {
private record Seen(String method, String path, String authorization, String requestedBy, String body) {
}
private final List<Seen> seen = new ArrayList<>();
@@ -60,7 +60,7 @@ class GatewayHttpCommandsTest {
private void answerCommands() {
server.createContext("/commands", exchange -> {
seen.add(new Seen(exchange.getRequestMethod(),
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
exchange.getRequestHeaders().getFirst("Authorization"),
exchange.getRequestHeaders().getFirst("X-Requested-By"),
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
@@ -136,6 +136,35 @@ class GatewayHttpCommandsTest {
assertEquals(8, state.restores().getFirst().originalValue());
}
@Test
void anUntilChangedRestoreHasNoDue() throws Exception {
serve(200, "{\"setpoints\":[],\"restores\":[{\"setpoint\":\"cwu_comfort\",\"register\":4144,"
+ "\"originalValue\":45,\"appliedValue\":10,\"leaseUntil\":\"2026-10-24T10:15:00Z\"}]}");
assertNull(commands("s3cret-token").setpoints().restores().getFirst().due());
}
@Test
void renewPostsToRenewWithTheTokenAndReturnsHowManyWereExtended() throws Exception {
serve(200, "{\"renewed\":2}");
assertEquals(2, commands("s3cret-token").renewLeases());
assertEquals("POST", seen.getFirst().method());
assertEquals("/commands/renew", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
}
@Test
void renewFailsOnAnyOtherStatusOrWithoutToken() {
serve(503, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").renewLeases());
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).renewLeases());
assertEquals(seenBefore, seen.size());
}
@Test
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
@@ -2,6 +2,7 @@ package home.backend.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.adapters.config.PresetFile;
import home.backend.application.GatewayClient;
@@ -109,10 +110,16 @@ class ApiServerTest {
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)),
List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"))));
}
@Override
public int renewLeases() {
return 0;
}
};
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
new HealthService(gateway, clock), new HistoryService(history, clock),
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
new SetpointService(commands, PresetFile.load(null)));
server.start();
}
@@ -137,14 +144,18 @@ class ApiServerTest {
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
"load_today_kwh", 24.2);
return new GatewaySnapshot(List.of(
return new GatewaySnapshot("v1.2.1", List.of(
new DeviceSnapshot("heat-pump", true, pumpTime, null,
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
new DeviceSnapshot("deye", true, energyTime, null,
List.of(new Measurement("home", "deye", deye, energyTime))),
new DeviceSnapshot("fronius", true, energyTime, null,
List.of(new Measurement("home", "fronius",
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime)))));
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime))),
new DeviceSnapshot("house-meter", true, energyTime, null,
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
"frequency", 50.01, "import_energy_active", 67514.27), energyTime)))));
}
private HttpResponse<String> get(String path) throws Exception {
@@ -178,16 +189,21 @@ class ApiServerTest {
assertMatchesExample("/api/energy", "energy");
}
@Test
void energyDetailsMatchExample() throws Exception {
assertMatchesExample("/api/energy/details", "energy-details");
}
@Test
void healthMatchesExample() throws Exception {
snapshot = new GatewaySnapshot(List.of(snapshot.devices().get(0),
snapshot = new GatewaySnapshot("v1.2.1", List.of(snapshot.devices().get(0),
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
assertMatchesExample("/api/health", "health");
}
@Test
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
snapshot = new GatewaySnapshot(List.of());
snapshot = new GatewaySnapshot("v1.2.1", List.of());
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
}
@@ -198,7 +214,7 @@ class ApiServerTest {
Measurement deye = devices.get(1).measurements().get(0);
devices.set(1, new DeviceSnapshot("deye", true, old, null,
List.of(new Measurement("home", "deye", deye.fields(), old))));
snapshot = new GatewaySnapshot(devices);
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/energy", "energy-deye-stale");
}
@@ -216,7 +232,7 @@ class ApiServerTest {
void unreachableGatewayIs503WithJsonError() throws Exception {
gatewayDown = true;
for (String path : List.of("/api/heat-pump", "/api/energy")) {
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/energy/details")) {
HttpResponse<String> response = get(path);
assertEquals(503, response.statusCode());
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
@@ -0,0 +1,86 @@
package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.SetpointState;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LeaseRenewerTest {
private final AtomicInteger calls = new AtomicInteger();
private final CountDownLatch threeCalls = new CountDownLatch(3);
private Exception failure;
private final GatewayCommands gateway = new GatewayCommands() {
@Override
public CommandResult apply(Command command) {
throw new UnsupportedOperationException();
}
@Override
public SetpointState setpoints() {
throw new UnsupportedOperationException();
}
@Override
public int renewLeases() throws IOException {
calls.incrementAndGet();
threeCalls.countDown();
if (failure instanceof IOException e) {
throw e;
}
if (failure instanceof RuntimeException e) {
throw e;
}
return 1;
}
};
@Test
void renewsAtOnceAndThenEveryPeriod() throws Exception {
try (var renewer = new LeaseRenewer(gateway)) {
renewer.start(Duration.ofMillis(10));
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
}
}
@Test
void aFailedRenewalDoesNotStopTheSchedule() throws Exception {
failure = new IOException("tunnel down");
try (var renewer = new LeaseRenewer(gateway)) {
renewer.start(Duration.ofMillis(10));
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
}
}
@Test
void anUnexpectedErrorDoesNotStopTheScheduleEither() throws Exception {
failure = new IllegalStateException("bad answer");
try (var renewer = new LeaseRenewer(gateway)) {
renewer.start(Duration.ofMillis(10));
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
}
}
@Test
void renewDoesNotThrowOutOfTheScheduler() {
failure = new IOException("tunnel down");
try (var renewer = new LeaseRenewer(gateway)) {
renewer.renew();
}
assertEquals(1, calls.get());
}
}
@@ -1,5 +1,9 @@
package home.backend.application;
import home.backend.domain.EnergyDetails;
import home.backend.domain.EnergyDetails.Device;
import home.backend.domain.EnergyDetails.Group;
import home.backend.domain.EnergyDetails.Item;
import home.backend.domain.EnergyView;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.GatewaySnapshot;
@@ -40,7 +44,7 @@ class ServicesTest {
}
private static GatewayClient gateway(DeviceSnapshot... devices) {
return () -> new GatewaySnapshot(List.of(devices));
return () -> new GatewaySnapshot("v1.2.1", List.of(devices));
}
private static GatewayClient unreachable() {
@@ -215,6 +219,80 @@ class ServicesTest {
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
}
private static EnergyDetails details(DeviceSnapshot... devices) throws IOException {
return new EnergyDetailsService(gateway(devices), CLOCK).current();
}
private static EnergyDetails.Device detailsOf(EnergyDetails details, String id) {
return details.devices().stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow();
}
@Test
void detailsShowOnlyReportedFieldsAndDropEmptyGroups() throws IOException {
EnergyDetails.Device deye = detailsOf(details(device("deye", "home", "deye",
Map.of("battery_soc_pct", 80.0, "grid_power_l2_w", 52.0, "pv1_power_w", 10.0))), "deye");
assertFalse(deye.stale());
assertEquals(READ, deye.time());
assertEquals(List.of("Bateria", "Sieć", "Panele PV"), deye.groups().stream().map(Group::title).toList());
assertEquals(List.of(new Item("Moc L2", 52.0, "W", 0)), deye.groups().get(1).items());
}
@Test
void detailsOfAStaleDeviceHaveNoValues() throws IOException {
Instant old = NOW.minusSeconds(3600);
var stale = new DeviceSnapshot("deye", true, old, null, List.of(new Measurement("home", "deye", DEYE, old)));
EnergyDetails.Device deye = detailsOf(details(stale), "deye");
assertTrue(deye.stale());
assertEquals(old, deye.time());
assertTrue(deye.groups().isEmpty());
}
@Test
void detailsListEveryDeviceEvenWhenTheGatewayKnowsNone() throws IOException {
EnergyDetails all = details();
assertEquals(List.of("deye", "fronius", "house-meter"), all.devices().stream().map(Device::id).toList());
assertTrue(all.devices().stream().allMatch(d -> d.stale() && d.groups().isEmpty()));
}
@Test
void detailsConvertFroniusWhToKwhAndRoundThem() throws IOException {
EnergyDetails.Device fronius = detailsOf(details(device("fronius", "home", "fronius",
Map.of("energy_day_wh", 4399.99, "energy_total_wh", 2.90713E7))), "fronius");
assertEquals(List.of(new Item("Dziś", 4.4, "kWh", 1), new Item("Od początku", 29071.0, "kWh", 0)),
fronius.groups().get(0).items());
}
@Test
void detailsReadTheHouseMeterPerPhase() throws IOException {
EnergyDetails.Device meter = detailsOf(details(device("house-meter", "electricity", "PRAD2",
Map.of("p1_voltage", 235.69, "p3_voltage", 232.2))), "house-meter");
assertEquals(List.of(new Item("Napięcie L1", 235.7, "V", 1), new Item("Napięcie L3", 232.2, "V", 1)),
meter.groups().get(0).items());
}
@Test
void detailsTakeThePhaseEnergyFromItsOwnMeasurement() throws IOException {
var meter = new DeviceSnapshot("house-meter", true, READ, null, List.of(
new Measurement("electricity", "PRAD2", Map.of("frequency", 50.01), READ),
new Measurement("electricity", "house_meter_phases", Map.of("l2_import_energy_active_kwh", 7012.4), READ)));
Group phases = detailsOf(details(meter), "house-meter").groups().getLast();
assertEquals("Energia na fazę", phases.title());
assertEquals(List.of(new Item("Pobrana L2", 7012.0, "kWh", 0)), phases.items());
}
@Test
void detailsFailWhenTheGatewayIsUnreachable() {
assertThrows(IOException.class, () -> new EnergyDetailsService(unreachable(), CLOCK).current());
}
private static DeviceSnapshot deyeRead(boolean up, Instant time) {
return new DeviceSnapshot("deye", up, time, null, List.of(new Measurement("home", "deye", DEYE, time)));
}
@@ -269,7 +347,7 @@ class ServicesTest {
DeviceSnapshot down = new DeviceSnapshot("deye", false, null, "connect timed out", List.of());
HealthView view = new HealthService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", Map.of()),
down), CLOCK).current();
down), CLOCK, "v1.3.0").current();
assertTrue(view.gatewayReachable());
assertEquals(NOW, view.time());
@@ -279,12 +357,22 @@ class ServicesTest {
assertEquals(READ, view.devices().get(0).lastSuccess());
}
@Test
void healthReportsBothVersions() {
HealthView view = new HealthService(gateway(), CLOCK, "v1.3.0").current();
assertEquals("v1.3.0", view.backendVersion());
assertEquals("v1.2.1", view.gatewayVersion());
}
@Test
void healthWithUnreachableGatewayHasNoDevices() {
HealthView view = new HealthService(unreachable(), CLOCK).current();
HealthView view = new HealthService(unreachable(), CLOCK, "v1.3.0").current();
assertFalse(view.gatewayReachable());
assertTrue(view.devices().isEmpty());
assertEquals("v1.3.0", view.backendVersion());
assertNull(view.gatewayVersion());
}
/** Remembers the request and answers with one point. */
@@ -55,6 +55,11 @@ class SetpointServiceTest {
}
return STATE;
}
@Override
public int renewLeases() {
return 0;
}
};
private final List<Preset> presets = PresetFile.load(null);
private final SetpointService service = new SetpointService(gateway, presets);
@@ -0,0 +1,63 @@
{
"devices": [
{
"id": "deye", "name": "Falownik Deye", "time": "2026-10-23T22:26:00Z", "stale": false,
"groups": [
{ "title": "Bateria", "items": [
{ "label": "Naładowanie", "value": 99.0, "unit": "%", "digits": 0 },
{ "label": "Moc", "value": -24.0, "unit": "W", "digits": 0 }
] },
{ "title": "Sieć", "items": [
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
] },
{ "title": "Odbiór domu", "items": [
{ "label": "Moc razem", "value": 570.0, "unit": "W", "digits": 0 }
] },
{ "title": "Panele PV", "items": [
{ "label": "PV1 moc", "value": 0.0, "unit": "W", "digits": 0 },
{ "label": "PV2 moc", "value": 0.0, "unit": "W", "digits": 0 }
] },
{ "title": "Dziś", "items": [
{ "label": "Produkcja PV", "value": 29.9, "unit": "kWh", "digits": 1 },
{ "label": "Zużycie domu", "value": 24.2, "unit": "kWh", "digits": 1 },
{ "label": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 }
] }
]
},
{
"id": "fronius", "name": "Falownik Fronius", "time": "2026-10-23T22:26:00Z", "stale": false,
"groups": [
{ "title": "Teraz", "items": [
{ "label": "Moc PV", "value": 0.0, "unit": "W", "digits": 0 }
] },
{ "title": "Produkcja", "items": [
{ "label": "Dziś", "value": 4.4, "unit": "kWh", "digits": 1 }
] }
]
},
{
"id": "house-meter", "name": "Licznik domu", "time": "2026-10-23T22:26:00Z", "stale": false,
"groups": [
{ "title": "Moc czynna", "items": [
{ "label": "Moc L1", "value": 56.0, "unit": "W", "digits": 0 }
] },
{ "title": "Napięcie", "items": [
{ "label": "Napięcie L1", "value": 235.7, "unit": "V", "digits": 1 }
] },
{ "title": "Prąd", "items": [
{ "label": "Prąd L1", "value": 0.32, "unit": "A", "digits": 2 }
] },
{ "title": "Współczynnik mocy", "items": [
{ "label": "cos φ L1", "value": 0.75, "unit": "", "digits": 2 }
] },
{ "title": "Sieć", "items": [
{ "label": "Częstotliwość", "value": 50.01, "unit": "Hz", "digits": 2 }
] },
{ "title": "Energia od początku", "items": [
{ "label": "Pobrana", "value": 67514.0, "unit": "kWh", "digits": 0 }
] }
]
}
]
}
@@ -1,6 +1,8 @@
{
"time": "2026-10-23T22:14:00Z",
"gatewayReachable": true,
"backendVersion": "v1.3.0",
"gatewayVersion": "v1.2.1",
"devices": [
{ "name": "heat-pump", "up": true, "lastSuccess": "2026-10-23T22:13:35Z", "lastError": null },
{ "name": "deye", "up": false, "lastSuccess": null, "lastError": "connect timed out" }
@@ -1 +1 @@
{"status":"UP","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
{"status":"UP","version":"v1.2.1","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
+34
View File
@@ -0,0 +1,34 @@
# ADR-011: Setpoint changes are leases that the backend renews
Status: accepted · 2026-10-24
## Context
ADR-006 restores a timed boost by itself, but a change "until changed" (away: CWU 10, CO 15)
stays forever. If the backend, the tunnel or the Kraków server is lost for good, the house stays
on those settings until someone notices. The requirement: a missing command at the gateway
reverses the change.
## Decision
- Every applied change, timed or not, keeps a restore with a **lease** of 15 minutes
(`leaseUntil`). The restore runs at the earlier of its end time (`due`, null for "until
changed") and the end of the lease, through the existing path: conflict check, read-back,
backoff.
- `POST /commands/renew` (token, same throttle as `/commands`) extends the lease to 15 minutes
from now for every restore that has not reached its run time. A lapsed, due or failing restore
is never revived.
- The backend calls it every 5 minutes from its start while `GATEWAY_TOKEN` is set. A failed call
is logged and retried; two in a row may fail without effect.
- A restore file entry without `leaseUntil` (older file) counts as lapsed and is restored on
start. `leaseUntil` is saved atomically like the rest.
- Limits and presets stay as agreed: CWU at most 48 °C (code ceiling 50), CO 5–25, restores at
most 72 h.
## Consequences
- "Until changed" means "while the backend runs and reaches the gateway". The UI says so.
- After a backend or tunnel outage longer than 15 minutes the house returns to the original
values; the user sets them again. That is the intended safe direction.
- One extra small file write on the Pi every 5 minutes while a change is active.
- A backend that is up but wrong about the world keeps changes alive; the gateway cannot tell.
+8 -9
View File
@@ -27,9 +27,11 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
- [x] Config file outside the jar (D3), wire `Main`, drop modbus4j, influxdb-java and the
baseline classes (D4, D5)
- [x] Three parts with their images and compose (ADR-010); releases by tag, `update.sh` on the Pi
- [ ] On the Pi, each step approved: D7, Docker, `gateway.json`, IIO overlay and reboot, stop
`rpi-measurements` before the gateway polls the meter, first `./update.sh`, then retire both
`rpi-measurements` and the old `home-pump.service`
- [x] On the Pi (2026-10-24): D7, Docker (no iptables, no forwarding), `gateway.json`, ufw 8080
on `wg0` only, `v1.1.1` running with the heat pump, Deye and Fronius; old service stopped
- [x] On the Pi (2026-10-24): IIO overlay, BME280 and the meter in the gateway (`v1.2.1`: the
meter's converter speaks RTU over TCP); `rpi-measurements` stopped and disabled
- [ ] After the week: remove `rpi-measurements` and the old `home-pump.service` files
## Next
@@ -37,7 +39,7 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|---|---|---|
| M2 Understand the controller | Timing (safe poll rate, response times), which pumps run in which state, open questions in `docs/frisko/` | `docs/frisko/` reviewed; F3 limits agreed |
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; Traefik route, Krosno only and no login for now (ADR-009). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); open: `REGISTRY_TOKEN`, first release, the stack in homelab-compose (Arcane) | My father sees the heat pump and the energy on his phone |
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
| M5 Alarms | F4; decide the channel first (e.g. ntfy, Telegram) | Simulated alarm reaches the phone |
| M6 Native app? | Decide: keep the web UI, WebView wrapper or React Native | Decision recorded |
@@ -51,11 +53,8 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
- D6 Development runs wrote test fields into the production `Dom_Pompa` database.
- D8 Each field is written as its own point with its own timestamp, so one poll can split into
points 1 ms apart (`InfluxMetricModbusSender`); write one point per poll.
- D7 Pi network: all 880 DNS errors of the week came in one hour (19.10, 10:00), likely an outage,
not config. But the Pi's WireGuard routes most of `192.168.0.0/24` into the tunnel (Kraków uses
the same subnet), so it reaches only `.10` and `.150` locally. Fix: Pi `AllowedIPs` only
`10.0.0.0/24, 10.2.0.0/24` (Influx and Gitea go via the public IP anyway), plus outbound NAT on
OPNsense's WireGuard interface so the Pi answers Kraków through the tunnel.
- D7 fixed 2026-10-24: Pi `AllowedIPs` only `10.0.0.0/24, 10.2.0.0/24`, and OPNsense NATs
`192.168.0.0/24 → 10.0.0.2` to its tunnel address, as both sites use `192.168.0.0/24`.
## Knowledge
+5 -4
View File
@@ -41,7 +41,7 @@ Out, until decided otherwise:
| Solar thermal collector | roof | Also heats the CWU tank; not connected to anything we read |
| Hybrid inverter Deye SUN-10K-SG04LP3-EU | logger `192.168.0.232:8899` | 10 kW, 3 phases, 48 V battery (backup only); most of the PV |
| PV inverter Fronius Symo 3.7-3-M | `192.168.0.143` | 4.2 kWp; local Solar API (JSON) |
| House meter SDM630 | RS485 gateway `192.168.0.10:502` | Whole house; read today by `rpi-measurements` into Influx `electricity`/`PRAD2`, moving into our gateway |
| House meter SDM630 | RS485 gateway `192.168.0.10:502` | Whole house; read today by `rpi-measurements` into Influx `electricity`/`PRAD2` (unchanged), moving into our gateway; per-phase energy counters go to `electricity`/`house_meter_phases` |
| Solar-Log | `192.168.0.210` | Old monitoring, no data since April 2026 |
| Raspberry Pi (`raspberrypi`) | `192.168.0.158` | Debian 11 (kept as is); runs the gateway (and `rpi-measurements` until the move); BME280 sensor; WireGuard `10.0.0.2` |
| My server | Kraków | Proxmox and Docker: backend, Traefik, InfluxDB 1.x, Grafana, Gitea. Same subnet `192.168.0.0/24` as Krosno; WireGuard on OPNsense |
@@ -78,15 +78,16 @@ Functional:
- F2 History for the last 7 days.
- F3 Change four setpoints: economy and comfort temperature of CWU (4143, 4144) and CO
(4131, 4132). Any value within limits, or one tap on a named preset. Limits and presets live in
the backend config: CWU 5–50 °C (pump maximum per my father; 48 °C proposed for margin, to
decide in M2), CO 5–25 °C.
the backend config: CWU 5–48 °C (decided; the gateway's ceiling stays 50), CO 5–25 °C.
- **Boost**: raise the economy temperature for a duration the user picks; the previous value
is restored automatically.
- **Away / not needed**: lower the comfort temperature (e.g. CWU 10 °C, CO 15 °C) until changed back.
- **Lease** (ADR-011): every change also needs the backend to renew it every 5 minutes; after
15 minutes without, the gateway restores the previous value.
- F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery).
- F5 Health of backend, device links and tunnel.
- F6 Later, low priority: show the weekly program (reading needs selector writes).
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals.
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals; a details panel per device (Deye, Fronius, house meter).
Safety, for every write:
+3
View File
@@ -4,6 +4,9 @@ WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
# The release tag, shown in the Stan screen; the release workflow passes it.
ARG VERSION=dev
ENV VERSION=$VERSION
# Tests run in scripts/check.sh; they read the API contract from backend/, outside this context.
RUN npm run build
+3
View File
@@ -13,3 +13,6 @@ docker compose -f frontend/compose.yaml up --build # after the backend's; .env
The overview picture is `research/ui/house-cutaway.webp`; `python3 scripts/house-assets.py` makes
the sizes and the pipe masks in `src/assets/` from it.
The release tag is baked in at build (`VERSION` build arg, `dev` by default) and shown with the backend's
and gateway's versions in the "Wersje" card of the Stan screen.
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#0e2a21">
<title>Dom Krosno</title>
<title>Dom Szczepańcowa</title>
<link rel="icon" href="data:,">
</head>
<body>
+2 -2
View File
@@ -60,7 +60,7 @@ function SideFooter() {
<Icon name="sun" className="size-9 text-amber-400" />
<div>
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
<p className="text-sm text-white/60">Krosno</p>
<p className="text-sm text-white/60">Szczepańcowa</p>
</div>
</div>
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
@@ -97,7 +97,7 @@ export function App() {
<div className="mb-8 hidden items-center gap-3 px-2 lg:flex">
<span className="grid size-10 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-6" /></span>
<div>
<p className="text-lg font-bold leading-tight">Dom Krosno</p>
<p className="text-lg font-bold leading-tight">Dom Szczepańcowa</p>
<p className="text-xs text-white/60">Pompa ciepła i energia</p>
</div>
</div>
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useRef } from "react";
import { quantity, when } from "./format";
import type { EnergyDetailsDevice } from "./types";
import { Row } from "./ui";
/** The tappable row that says in words and with a chevron that a device has more to show. */
export function DetailsButton({ device, onOpen }: { device: EnergyDetailsDevice; onOpen: () => void }) {
return (
<button type="button" onClick={onOpen}
className="flex min-h-14 w-full items-center justify-between gap-3 rounded-xl border border-edge px-4 py-2 text-left hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
<span>
<span className="block font-semibold">{device.name}</span>
{device.stale && <span className="block text-xs text-soft">brak odczytu</span>}
</span>
<span className="flex shrink-0 items-center gap-1 font-semibold text-brand">
Szczegóły <span aria-hidden="true" className="text-xl leading-none">›</span>
</span>
</button>
);
}
/** A bottom sheet on a phone, a dialog on a wide screen; closes by button, backdrop or Escape. */
export function DeviceDetails({ device, onClose }: { device: EnergyDetailsDevice; onClose: () => void }) {
const close = useRef<HTMLButtonElement>(null);
useEffect(() => {
const opener = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
close.current?.focus();
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
// The close button is the only control inside, so Tab stays on it.
if (event.key === "Tab") {
event.preventDefault();
close.current?.focus();
}
};
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = overflow;
opener?.focus();
};
}, [onClose]);
return (
<div data-testid="backdrop" className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center"
onClick={onClose}>
<div role="dialog" aria-modal="true" aria-labelledby="details-title" onClick={(event) => event.stopPropagation()}
className="flex max-h-[85dvh] w-full flex-col rounded-t-2xl border border-edge bg-panel shadow-xl sm:max-w-lg sm:rounded-2xl">
<header className="flex items-start justify-between gap-3 border-b border-edge p-4">
<div>
<h2 id="details-title" className="text-lg font-semibold">{device.name}</h2>
{device.time && <p className="text-sm text-soft">Odczyt z {when(device.time)}</p>}
</div>
<button ref={close} type="button" onClick={onClose}
className="min-h-11 min-w-11 rounded-xl px-3 font-semibold text-brand hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
Zamknij
</button>
</header>
<div className="overflow-y-auto p-4">
{device.groups.length === 0 && (
<p className="text-soft">
Brak aktualnych danych{device.time ? ` (ostatni odczyt ${when(device.time)})` : ""}.
</p>
)}
{device.groups.map((group) => (
<section key={group.title} className="mb-4 last:mb-0">
<h3 className="mb-1 text-sm font-semibold uppercase tracking-wide text-soft">{group.title}</h3>
{group.items.map((item) => (
<Row key={item.label} label={item.label} value={quantity(item.value, item.unit, item.digits)} />
))}
</section>
))}
</div>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { EnergyDetails } from "./types";
// A fresh module per test: the poller keeps its answers between screens, not between tests.
const energy = async () => (await import("./Energy")).Energy;
function serveEnergy(details: EnergyDetails = example("energy-details")) {
serve({ "api/energy/details": details, "api/energy": example("energy") });
}
describe("Energy details", () => {
it("offers every device with a visible way to open it", async () => {
serveEnergy();
const Energy = await energy();
render(<Energy />);
for (const name of ["Falownik Deye", "Falownik Fronius", "Licznik domu"]) {
expect(await screen.findByRole("button", { name: new RegExp(`${name}.*Szczegóły`) })).toBeInTheDocument();
}
expect(screen.queryByRole("dialog")).toBeNull();
});
it("opens the groups of a device and closes by button, backdrop and Escape", async () => {
serveEnergy();
const Energy = await energy();
render(<Energy />);
const opener = await screen.findByRole("button", { name: /Falownik Deye/ });
await userEvent.click(opener);
const dialog = screen.getByRole("dialog", { name: "Falownik Deye" });
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(within(dialog).getByRole("button", { name: "Zamknij" })).toHaveFocus();
expect(within(dialog).getByRole("heading", { name: "Bateria" })).toBeInTheDocument();
expect(within(dialog).getByText("Naładowanie").parentElement).toHaveTextContent("99 %");
expect(within(dialog).getByText("Pobrano z sieci").parentElement).toHaveTextContent("16,8 kWh");
await userEvent.click(within(dialog).getByRole("button", { name: "Zamknij" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(opener).toHaveFocus();
await userEvent.click(opener);
await userEvent.click(screen.getByTestId("backdrop"));
expect(screen.queryByRole("dialog")).toBeNull();
await userEvent.click(opener);
await userEvent.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).toBeNull();
});
it("stays open when the panel itself is tapped", async () => {
serveEnergy();
const Energy = await energy();
render(<Energy />);
await userEvent.click(await screen.findByRole("button", { name: /Licznik domu/ }));
await userEvent.click(screen.getByRole("heading", { name: "Licznik domu" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("says there is no current data for a device without a fresh reading", async () => {
const details = example<EnergyDetails>("energy-details");
serveEnergy({ devices: details.devices.map((d) => (d.id === "fronius" ? { ...d, stale: true, groups: [] } : d)) });
const Energy = await energy();
render(<Energy />);
await userEvent.click(await screen.findByRole("button", { name: /Falownik Fronius/ }));
expect(screen.getByRole("dialog")).toHaveTextContent("Brak aktualnych danych");
});
});
+21 -1
View File
@@ -1,8 +1,10 @@
import { useCallback, useState } from "react";
import { Cell, Pie, PieChart } from "recharts";
import { kwh, percent, power, when } from "./format";
import { Icon, type IconName } from "./icons";
import { usePoll } from "./poll";
import type { Energy as EnergyData } from "./types";
import type { Energy as EnergyData, EnergyDetails } from "./types";
import { DetailsButton, DeviceDetails } from "./DeviceDetails";
import { Banner, Card, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
const FLOWING_W = 10;
@@ -138,6 +140,23 @@ function Stat({ icon, label, value, note }: { icon: IconName; label: string; val
);
}
function Devices() {
const { data } = usePoll<EnergyDetails>("api/energy/details");
const [openId, setOpenId] = useState<string | null>(null);
const close = useCallback(() => setOpenId(null), []);
const open = data?.devices.find((d) => d.id === openId);
return (
<Card title="Urządzenia" className="lg:col-span-3">
{!data ? <Loading /> : (
<div className="grid gap-3 sm:grid-cols-3">
{data.devices.map((d) => <DetailsButton key={d.id} device={d} onOpen={() => setOpenId(d.id)} />)}
</div>
)}
{open && <DeviceDetails device={open} onClose={close} />}
</Card>
);
}
export function Energy() {
const { data: e, offline } = usePoll<EnergyData>("api/energy");
const title = <PageTitle title="Energia" subtitle="Falowniki Deye i Fronius, licznik domu" />;
@@ -157,6 +176,7 @@ export function Energy() {
<Stat icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Stat icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
</div>
<Devices />
</div>
</>
);
+12
View File
@@ -71,6 +71,18 @@ describe("Settings", () => {
expect(alert).toHaveTextContent("Ogrzewanie domu, komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
});
it("labels a change without an end time as lasting while the server runs", async () => {
const data = example<Setpoints>("setpoints");
data.restores = [{ setpoint: "cwu_comfort", register: 4144, originalValue: 45, appliedValue: 10, due: null }];
serve({ "api/setpoints": data });
const Settings = await settings();
render(<Settings />);
expect(
await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
).toBeInTheDocument();
});
it("does not offer a change built on a missing reading", async () => {
const data = example<Setpoints>("setpoints");
data.setpoints[2].value = null;
+1 -1
View File
@@ -176,7 +176,7 @@ export function Settings() {
<Card title="Zmiany czasowe">
{data.restores.map((r) => (
<p key={r.setpoint} className="my-1">
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> do {when(r.due)}, potem <b>{degrees(r.originalValue)}</b>.
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> {r.due ? `do ${when(r.due)}` : "do odwołania (dopóki serwer działa)"}, potem <b>{degrees(r.originalValue)}</b>.
</p>
))}
</Card>
+30
View File
@@ -0,0 +1,30 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { Health } from "./types";
const status = async () => (await import("./Status")).Status;
const versions = () => screen.getByText("Wersje").closest("section")!;
describe("Status", () => {
it("shows the version of each component", async () => {
serve({ "api/health": example("health") });
const Status = await status();
render(<Status />);
expect(await screen.findByText("Wersje")).toBeInTheDocument();
expect(versions()).toHaveTextContent(`Aplikacja${__APP_VERSION__}`);
expect(versions()).toHaveTextContent("Serwerv1.3.0");
expect(versions()).toHaveTextContent("Bramkav1.2.1");
});
it("says unknown for a gateway that does not report its version", async () => {
serve({ "api/health": { ...example<Health>("health"), gatewayReachable: false, gatewayVersion: null } });
const Status = await status();
render(<Status />);
expect(await screen.findByText("Wersje")).toBeInTheDocument();
expect(versions()).toHaveTextContent("Bramkanieznana");
expect(versions()).toHaveTextContent("Serwerv1.3.0");
});
});
+5
View File
@@ -38,6 +38,11 @@ export function Status() {
);
})}
</Card>
<Card title="Wersje">
<Row label="Aplikacja" value={__APP_VERSION__} />
<Row label="Serwer" value={data.backendVersion} />
<Row label="Bramka" value={data.gatewayVersion ?? "nieznana"} />
</Card>
</div>
</>
);
+4
View File
@@ -18,6 +18,10 @@ export const kwh = (value: Value) => (value == null ? DASH : `${number(value, 1)
// "45 °C" for a whole setpoint, "45,5 °C" otherwise.
export const degrees = (value: Value) => (value == null ? DASH : `${number(value, 1).replace(/,0$/, "")} °C`);
// A value in the unit the backend names, with the precision it asks for.
export const quantity = (value: number, unit: string, digits: number) =>
unit ? `${number(value, digits)} ${unit}` : number(value, digits);
export function power(watts: Value): string {
if (watts == null) return DASH;
return Math.abs(watts) >= 1000 ? `${number(watts / 1000, 1)} kW` : `${number(watts)} W`;
+23 -1
View File
@@ -37,6 +37,25 @@ export interface Energy {
today: { pvKwh: number | null; gridBoughtKwh: number | null; gridSoldKwh: number | null; loadKwh: number | null };
}
export interface EnergyDetailsItem {
label: string;
value: number;
unit: string;
digits: number;
}
export interface EnergyDetailsDevice {
id: string;
name: string;
time: string | null;
stale: boolean;
groups: { title: string; items: EnergyDetailsItem[] }[];
}
export interface EnergyDetails {
devices: EnergyDetailsDevice[];
}
export interface Point {
t: string;
v: number;
@@ -51,6 +70,8 @@ export interface History {
export interface Health {
time: string;
gatewayReachable: boolean;
backendVersion: string;
gatewayVersion: string | null;
devices: { name: string; up: boolean; lastSuccess: string | null; lastError: string | null }[];
}
@@ -68,7 +89,8 @@ export interface Restore {
register: number;
originalValue: number;
appliedValue: number;
due: string;
// null: until changed, which holds only while the backend keeps renewing the lease (ADR-011)
due: string | null;
}
export interface Change {
+2
View File
@@ -0,0 +1,2 @@
/** The frontend's own release tag, defined at build time in vite.config.ts. */
declare const __APP_VERSION__: string;
+2
View File
@@ -6,6 +6,8 @@ import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
base: "./",
// The release tag from the Dockerfile's VERSION build arg; "dev" for a local build.
define: { __APP_VERSION__: JSON.stringify(process.env.VERSION ?? "dev") },
build: { outDir: "dist", emptyOutDir: true },
// `npm run dev` asks a running backend for /api: BACKEND, by default the backend's own default port.
server: { proxy: { "/api": process.env.BACKEND ?? "http://localhost:8080" } },
+3
View File
@@ -8,6 +8,9 @@ COPY src src
RUN mvn -B -q package -Dmaven.test.skip=true
FROM eclipse-temurin:21-jre
# The release tag, shown in /health; the release workflow passes it.
ARG VERSION=dev
ENV VERSION=$VERSION
COPY --from=build /src/target/home-pump-1.0.0.jar /app/gateway.jar
# Config, spool, restore file and token live on the Pi under the same path (compose.yaml).
ENTRYPOINT ["java", "-jar", "/app/gateway.jar"]
+18 -2
View File
@@ -37,6 +37,7 @@ only the heat pump enabled. A mistake stops the start with a message naming the
- `devices`: `heatPump`, `houseMeter`, `deye`, `fronius`, `bme280`, each with `enabled`. Modbus links need
`minimumPauseMs` above 0. Enable the meter only after stopping `rpi-measurements`, BME280 after the IIO
overlay, Deye and Fronius after the D7 network fix.
The meter writes `electricity.PRAD2` (legacy, fixed) and `electricity.house_meter_phases` (per-phase kWh and kvarh).
- `commands`: the setpoint write path (`POST /commands`), off unless `enabled` is true, and it needs the
heat pump enabled. `tokenFile`, `restoreFile`, `minIntervalSeconds` (60), `readBackDelayMillis` (1000),
`restoreCheckSeconds` (30) and `setpoints`: the allowlist `{name, register, min, max, step}`, limited to
@@ -52,9 +53,24 @@ Do all of this before setting `commands.enabled` to true:
3. Only the backend may reach port 8080 (S6; the gateway has no other access control): set `http.bind` to
the WireGuard or LAN address the backend uses, or add a firewall rule on the Pi.
4. Traefik must pass the `Host` header unchanged: the backend's CSRF check compares it with `Origin`.
5. Confirm the CWU maximum (48 degrees C) in M2 against the controller documentation; the allowlist in
`gateway.json` must not exceed it.
5. The allowlist in `gateway.json` holds the agreed limits: CWU at most 48 degrees C, CO 5 to 25 (the code
ceiling for CWU is 50).
Then set `commands.enabled` and `docker restart home-pump-gateway`. If the restore file is corrupt at start, the gateway
keeps polling and serving, moves the file aside, and `/commands` answers 503 until a restart.
## Leases (ADR-011)
Every applied change, timed or "until changed", keeps a restore with a 15 minute lease. The restore runs at
the earlier of its end time and the end of the lease, through the usual path (conflict check, read-back,
backoff). `POST /commands/renew` (same token and throttle as `/commands`, no body) extends the lease of the
restores that have not reached their run time and answers `{"renewed": n}`. The backend calls it every
5 minutes. A lapsed or failing restore is never revived. Without the backend, or with the tunnel down, a
change therefore ends 15 minutes after the last renewal. `restores.json` entries carry `leaseUntil`; one
without it (older file) counts as lapsed and is restored on start. `GET /commands` lists both `due`
(absent for "until changed") and `leaseUntil`.
## Version
`/health` has a top-level `version`: the release tag the image was built with (`VERSION` build arg and
environment variable, set by the release workflow), `dev` for a local build.
@@ -165,7 +165,9 @@ public final class Gateway implements AutoCloseable {
result.add(new HeatPumpDevice(heatPumpModbus, heatPump.settings()));
}
if (Switchable.isOn(devices.houseMeter())) {
result.add(new HouseMeterDevice(modbusOverTcp(devices.houseMeter().connection())));
// Its RS485 converter forwards RTU frames as they are, not Modbus TCP.
var meterLink = devices.houseMeter().connection();
result.add(new HouseMeterDevice(new PduModbusClient(own(TcpTransport.rtuOverTcp(meterLink.settings())))));
}
if (Switchable.isOn(devices.deye())) {
result.add(new DeyeDevice(deyeModbus(devices.deye())));
@@ -26,9 +26,21 @@ public final class DeyeDevice implements Device {
/** 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));
new Block(500, 1), new Block(514, 22), new Block(540, 2), new Block(586, 6),
new Block(598, 3), new Block(609, 1), new Block(622, 4), new Block(636, 1),
new Block(650, 4), new Block(672, 8));
/** A 32-bit counter in /10 kWh, low word first. */
private record Counter(String name, int lowRegister) {
}
private static final List<Counter> COUNTERS = List.of(
new Counter("battery_charge_total_kwh", 516),
new Counter("battery_discharge_total_kwh", 518),
new Counter("grid_bought_total_kwh", 522),
new Counter("grid_sold_total_kwh", 524),
new Counter("load_total_kwh", 527),
new Counter("pv_total_kwh", 534));
private static final List<Field> FIELDS = List.of(
new Field("battery_charge_today_kwh", 514, scaled(10)),
@@ -53,7 +65,17 @@ public final class DeyeDevice implements Device {
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)));
new Field("pv2_power_w", 673, scaled(1)),
new Field("run_state", 500, scaled(1)),
new Field("inverter_temperature_c", 540, x -> (x - 1000) / 10),
new Field("heatsink_temperature_c", 541, x -> (x - 1000) / 10),
new Field("battery_current_a", 591, x -> signed(x) / 100),
new Field("grid_frequency_hz", 609, scaled(100)),
new Field("inverter_power_w", 636, DeyeDevice::signed),
new Field("pv1_voltage_v", 676, scaled(10)),
new Field("pv1_current_a", 677, scaled(10)),
new Field("pv2_voltage_v", 678, scaled(10)),
new Field("pv2_current_a", 679, scaled(10)));
private final ModbusClient modbus;
@@ -78,6 +100,11 @@ public final class DeyeDevice implements Device {
for (Field field : FIELDS) {
fields.put(field.name(), field.decode().applyAsDouble(words.get(field.register())));
}
for (Counter counter : COUNTERS) {
int low = words.get(counter.lowRegister());
long high = words.get(counter.lowRegister() + 1);
fields.put(counter.name(), ((high << 16) | low) / 10.0);
}
return List.of(new Reading("electricity", "deye", Map.of("location", "dom-szczepancowa"),
fields, now));
}
@@ -25,7 +25,8 @@ import static home.gateway.adapters.http.GatewayHttpServer.respond;
/**
* {@code POST /commands} applies one setpoint change, {@code GET /commands} lists the setpoints
* and pending restores. Only the backend may call it (S6): every request needs the shared token.
* and pending restores, {@code POST /commands/renew} keeps the live restores alive (ADR-011)
* and answers with how many it extended. Only the backend may call it (S6): every request needs the shared token.
* HTTP status follows the outcome: 200 applied, 422 rejected, 502 failed on the controller side.
* 503 when the write path is unavailable ({@link CommandsUnavailableException}).
* Repeated bad tokens lead to 429 for a while ({@link FailedLoginThrottle}).
@@ -39,10 +40,15 @@ class CommandsEndpoint implements HttpHandler {
private static final Pattern REQUESTER = Pattern.compile("[\\w.@+-]{1,64}");
private static final String BEARER = "Bearer ";
private static final String HEAT_PUMP = "heat_pump";
private static final String COMMANDS_PATH = "/commands";
private static final String RENEW_PATH = "/commands/renew";
private record SetpointView(String name, int register, int min, int max, int step, Integer value) {
}
private record Renewal(int renewed) {
}
private record Listing(List<SetpointView> setpoints, List<Restore> restores) {
}
@@ -66,7 +72,8 @@ class CommandsEndpoint implements HttpHandler {
public void handle(HttpExchange exchange) throws IOException {
try (exchange) {
var method = exchange.getRequestMethod();
if (!exchange.getRequestURI().getPath().equals("/commands")) {
var path = exchange.getRequestURI().getPath();
if (!path.equals(COMMANDS_PATH) && !path.equals(RENEW_PATH)) {
respond(exchange, 404, new GatewayHttpServer.Error("not found"));
} else if (throttle.isBlocked()) {
exchange.getResponseHeaders().set("Retry-After",
@@ -77,14 +84,16 @@ class CommandsEndpoint implements HttpHandler {
exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer");
respond(exchange, 401, new GatewayHttpServer.Error("unauthorized"));
} else {
dispatch(exchange, method);
dispatch(exchange, method, path);
}
}
}
private void dispatch(HttpExchange exchange, String method) throws IOException {
private void dispatch(HttpExchange exchange, String method, String path) throws IOException {
try {
if (method.equals("GET")) {
if (path.equals(RENEW_PATH)) {
renew(exchange, method);
} else if (method.equals("GET")) {
respond(exchange, 200, listing());
} else if (method.equals("POST")) {
post(exchange);
@@ -97,6 +106,15 @@ class CommandsEndpoint implements HttpHandler {
}
}
private void renew(HttpExchange exchange, String method) throws IOException {
if (method.equals("POST")) {
respond(exchange, 200, new Renewal(commands.renewLeases()));
} else {
exchange.getResponseHeaders().set("Allow", "POST");
respond(exchange, 405, new GatewayHttpServer.Error("method not allowed"));
}
}
private boolean authorized(HttpExchange exchange) {
var header = exchange.getRequestHeaders().getFirst("Authorization");
if (header == null || !header.startsWith(BEARER)) {
@@ -14,6 +14,7 @@ import java.net.InetSocketAddress;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Executors;
@@ -28,7 +29,7 @@ public class GatewayHttpServer implements AutoCloseable {
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
}
private record Health(String status, List<DeviceHealth> devices) {
private record Health(String status, String version, List<DeviceHealth> devices) {
}
record Error(String error) {
@@ -39,6 +40,9 @@ public class GatewayHttpServer implements AutoCloseable {
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS);
/** The release tag, set as VERSION by the image; "dev" for a local build. */
private static final String VERSION = Objects.requireNonNullElse(System.getenv("VERSION"), "dev");
public static final int MIN_TOKEN_LENGTH = CommandsEndpoint.MIN_TOKEN_LENGTH;
private final Snapshot snapshot;
@@ -101,7 +105,7 @@ public class GatewayHttpServer implements AutoCloseable {
.allMatch(GatewayHttpServer::isUp);
boolean allUp = devices.stream().allMatch(GatewayHttpServer::isUp);
var status = !criticalUp ? "DOWN" : allUp ? "UP" : "DEGRADED";
respond(exchange, criticalUp ? 200 : 503, new Health(status, devices));
respond(exchange, criticalUp ? 200 : 503, new Health(status, VERSION, devices));
}
private static boolean isUp(DeviceHealth device) {
@@ -5,6 +5,9 @@ import home.gateway.adapters.modbus.ModbusClient;
import home.gateway.application.Device;
import home.gateway.domain.Reading;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -14,11 +17,17 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
/** The Eastron SDM630 house meter: all input registers, one point per poll. */
/**
* The Eastron SDM630 house meter: all input registers as the legacy PRAD2 point, plus the
* per-phase energy counters as a second point of the same poll.
*/
public final class HouseMeterDevice implements Device {
private static final Logger log = LoggerFactory.getLogger(HouseMeterDevice.class);
private static final Map<String, String> TAGS = Map.of("location", "dom-szczepancowa");
private static final int UNIT_ID = 1;
private static final int MAX_REGISTERS_PER_REQUEST = 125;
private static final Duration INTERVAL = Duration.ofSeconds(60);
@@ -29,6 +38,7 @@ public final class HouseMeterDevice implements Device {
private final ModbusClient modbus;
private final List<Block> blocks = blocksOf(Sdm630Registers.INPUT);
private final Block phaseEnergyBlock = blocksOf(Sdm630Registers.PHASE_ENERGY).getFirst();
public HouseMeterDevice(ModbusClient modbus) {
this.modbus = modbus;
@@ -53,8 +63,25 @@ public final class HouseMeterDevice implements Device {
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));
List<Reading> readings = new ArrayList<>();
readings.add(new Reading("electricity", "PRAD2", TAGS, fields, now));
readPhaseEnergy(now).ifPresent(readings::add);
return readings;
}
/** Extra data must never cost the legacy point: a failed request is logged and skipped. */
private Optional<Reading> readPhaseEnergy(Instant now) {
Map<String, Double> fields = new LinkedHashMap<>();
try {
readBlock(phaseEnergyBlock, fields);
} catch (IOException e) {
log.warn("House meter phase energy not read, PRAD2 is still written", e);
return Optional.empty();
}
if (fields.isEmpty()) {
return Optional.empty();
}
return Optional.of(new Reading("electricity", "house_meter_phases", TAGS, fields, now));
}
private void readBlock(Block block, Map<String, Double> fields) throws IOException {
@@ -1,11 +1,13 @@
package home.gateway.adapters.meter;
import java.util.ArrayList;
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.
* The per-phase energy counters below have their own measurement.
*/
final class Sdm630Registers {
@@ -88,6 +90,30 @@ final class Sdm630Registers {
new Register("total_energy_reactive", 0x0158, 4)
);
private static final int PHASE_ENERGY_FIRST = 0x015a;
private static final int PHASES = 3;
private static final List<String> PHASE_ENERGY_KINDS = List.of(
"import_energy_active_kwh", "export_energy_active_kwh", "total_energy_active_kwh",
"import_energy_reactive_kvarh", "export_energy_reactive_kvarh",
"total_energy_reactive_kvarh");
/**
* Per-phase energy counters, 0x015A to 0x017D: for each kind in turn the phases L1, L2, L3.
* All are fetched with one request.
*/
static final List<Register> PHASE_ENERGY = phaseEnergy();
private static List<Register> phaseEnergy() {
List<Register> registers = new ArrayList<>();
for (String kind : PHASE_ENERGY_KINDS) {
for (int phase = 1; phase <= PHASES; phase++) {
int address = PHASE_ENERGY_FIRST + WORDS_PER_VALUE * registers.size();
registers.add(new Register("l" + phase + "_" + kind, address, 1));
}
}
return List.copyOf(registers);
}
private Sdm630Registers() {
}
}
@@ -0,0 +1,13 @@
package home.gateway.adapters.modbus;
import java.io.DataInputStream;
import java.io.IOException;
/** How one request PDU and its response travel over the socket of a {@link TcpTransport}. */
interface Framing {
byte[] frame(int unitId, byte[] pdu);
/** The response PDU, function code first, for the request framed last. */
byte[] readResponse(DataInputStream in, int unitId) throws IOException;
}
@@ -0,0 +1,46 @@
package home.gateway.adapters.modbus;
import java.io.DataInputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
/** Modbus TCP: an MBAP header with a transaction id before the PDU. */
final class MbapFraming implements Framing {
private static final int HEADER_LENGTH = 7;
private int transactionId;
@Override
public byte[] frame(int unitId, byte[] pdu) {
transactionId = (transactionId + 1) & 0xFFFF;
return ByteBuffer.allocate(HEADER_LENGTH + pdu.length)
.putShort((short) transactionId)
.putShort((short) 0)
.putShort((short) (pdu.length + 1))
.put((byte) unitId)
.put(pdu)
.array();
}
@Override
public byte[] readResponse(DataInputStream in, int expectedUnitId) throws IOException {
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(HEADER_LENGTH));
if (header.remaining() < HEADER_LENGTH) {
throw new IOException("connection closed by peer");
}
int id = header.getShort() & 0xFFFF;
int protocol = header.getShort() & 0xFFFF;
int length = header.getShort() & 0xFFFF;
int unitId = header.get() & 0xFF;
if (protocol != 0 || id != transactionId || unitId != expectedUnitId || length < 2) {
throw new IOException("unexpected MBAP header: transaction %d, protocol %d, length %d, unit %d"
.formatted(id, protocol, length, unitId));
}
byte[] pdu = in.readNBytes(length - 1);
if (pdu.length < length - 1) {
throw new IOException("connection closed by peer");
}
return pdu;
}
}
@@ -0,0 +1,74 @@
package home.gateway.adapters.modbus;
import java.io.DataInputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
/**
* Modbus RTU frames (unit, PDU, CRC-16) sent as they are over TCP, as a plain RS485 converter
* forwards them. There is no length header, so the response length follows from its function code;
* only reads are framed, as the house meter is never written.
*/
final class RtuFraming implements Framing {
private static final int CRC_LENGTH = 2;
@Override
public byte[] frame(int unitId, byte[] pdu) {
ByteBuffer frame = ByteBuffer.allocate(1 + pdu.length + CRC_LENGTH).put((byte) unitId).put(pdu);
int crc = crc(frame.array(), 1 + pdu.length);
return frame.put((byte) crc).put((byte) (crc >>> 8)).array();
}
@Override
public byte[] readResponse(DataInputStream in, int expectedUnitId) throws IOException {
byte[] head = readExactly(in, 2);
int unitId = head[0] & 0xFF;
int function = head[1] & 0xFF;
if (unitId != expectedUnitId) {
throw new IOException("RTU response from unit " + unitId + ", expected " + expectedUnitId);
}
byte[] body = switch (function) {
case 3, 4 -> withCount(in);
default -> {
if ((function & 0x80) == 0) {
throw new IOException("RTU response with unsupported function " + function);
}
yield readExactly(in, 1);
}
};
byte[] crcBytes = readExactly(in, CRC_LENGTH);
byte[] frame = ByteBuffer.allocate(2 + body.length).put(head).put(body).array();
int expected = crc(frame, frame.length);
int received = (crcBytes[0] & 0xFF) | (crcBytes[1] & 0xFF) << 8;
if (received != expected) {
throw new IOException("RTU response CRC %04x, expected %04x".formatted(received, expected));
}
return ByteBuffer.allocate(1 + body.length).put((byte) function).put(body).array();
}
private static byte[] withCount(DataInputStream in) throws IOException {
int count = readExactly(in, 1)[0] & 0xFF;
return ByteBuffer.allocate(1 + count).put((byte) count).put(readExactly(in, count)).array();
}
private static byte[] readExactly(DataInputStream in, int length) throws IOException {
byte[] bytes = in.readNBytes(length);
if (bytes.length < length) {
throw new IOException("connection closed by peer");
}
return bytes;
}
/** Modbus CRC-16: polynomial 0xA001, start 0xFFFF; sent low byte first. */
static int crc(byte[] bytes, int length) {
int crc = 0xFFFF;
for (int i = 0; i < length; i++) {
crc ^= bytes[i] & 0xFF;
for (int bit = 0; bit < 8; bit++) {
crc = (crc & 1) != 0 ? (crc >>> 1) ^ 0xA001 : crc >>> 1;
}
}
return crc;
}
}
@@ -5,7 +5,6 @@ import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@@ -13,10 +12,11 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
/**
* Modbus TCP (MBAP). The MK01 gateway serves a single client and a second connection drops the
* first, so requests never overlap and the socket is reused for the requests of one poll. It is
* closed after a few idle seconds: MK01 resets a connection left idle for minutes, which cost every
* other poll, and while one is open the vendor's app cannot connect.
* Modbus over a TCP socket: Modbus TCP (MBAP) or RTU frames as an RS485 converter forwards them.
* The MK01 gateway serves a single client and a second connection drops the first, so requests
* never overlap and the socket is reused for the requests of one poll. It is closed after a few
* idle seconds: MK01 resets a connection left idle for minutes, which cost every other poll, and
* while one is open the vendor's app cannot connect.
*/
public final class TcpTransport implements ModbusTransport {
@@ -24,26 +24,32 @@ public final class TcpTransport implements ModbusTransport {
Duration minimumPause) {
}
private static final int HEADER_LENGTH = 7;
static final Duration IDLE_CLOSE = Duration.ofSeconds(5);
// Not synchronized: a virtual thread blocked in a monitor pins its carrier thread.
private final ReentrantLock lock = new ReentrantLock();
private final Settings settings;
private final Framing framing;
private final Duration idleClose;
private final ScheduledExecutorService idleCloser = Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("modbus-idle-close").factory());
private Socket socket;
private DataInputStream in;
private int transactionId;
private long lastResponseNanos;
/** Modbus TCP, as MK01 speaks it. */
public TcpTransport(Settings settings) {
this(settings, IDLE_CLOSE);
this(settings, new MbapFraming(), IDLE_CLOSE);
}
TcpTransport(Settings settings, Duration idleClose) {
/** RTU frames with their CRC and no MBAP header, as a plain RS485 converter forwards them. */
public static TcpTransport rtuOverTcp(Settings settings) {
return new TcpTransport(settings, new RtuFraming(), IDLE_CLOSE);
}
TcpTransport(Settings settings, Framing framing, Duration idleClose) {
this.settings = settings;
this.framing = framing;
this.idleClose = idleClose;
this.lastResponseNanos = System.nanoTime() - settings.minimumPause().toNanos();
}
@@ -62,9 +68,8 @@ public final class TcpTransport implements ModbusTransport {
try {
awaitPause();
connectIfNeeded();
int id = nextTransactionId();
socket.getOutputStream().write(frame(id, unitId, requestPdu));
byte[] response = readResponse(id, unitId);
socket.getOutputStream().write(framing.frame(unitId, requestPdu));
byte[] response = framing.readResponse(in, unitId);
lastResponseNanos = System.nanoTime();
if (!idleCloser.isShutdown()) {
idleCloser.schedule(this::closeIfIdle, idleClose.toNanos(), TimeUnit.NANOSECONDS);
@@ -130,41 +135,6 @@ public final class TcpTransport implements ModbusTransport {
in = new DataInputStream(candidate.getInputStream());
}
private int nextTransactionId() {
transactionId = (transactionId + 1) & 0xFFFF;
return transactionId;
}
private static byte[] frame(int transactionId, int unitId, byte[] pdu) {
return ByteBuffer.allocate(HEADER_LENGTH + pdu.length)
.putShort((short) transactionId)
.putShort((short) 0)
.putShort((short) (pdu.length + 1))
.put((byte) unitId)
.put(pdu)
.array();
}
private byte[] readResponse(int expectedTransactionId, int expectedUnitId) throws IOException {
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(HEADER_LENGTH));
if (header.remaining() < HEADER_LENGTH) {
throw new IOException("connection closed by peer");
}
int id = header.getShort() & 0xFFFF;
int protocol = header.getShort() & 0xFFFF;
int length = header.getShort() & 0xFFFF;
int unitId = header.get() & 0xFF;
if (protocol != 0 || id != expectedTransactionId || unitId != expectedUnitId || length < 2) {
throw new IOException("unexpected MBAP header: transaction %d, protocol %d, length %d, unit %d"
.formatted(id, protocol, length, unitId));
}
byte[] pdu = in.readNBytes(length - 1);
if (pdu.length < length - 1) {
throw new IOException("connection closed by peer");
}
return pdu;
}
private void closeSocket() {
if (socket == null) {
return;
@@ -88,25 +88,31 @@ public class JsonRestoreStore implements RestoreStore {
}
}
/** The file format: every field required except otherApplied, which only a lost reply sets. */
/**
* The file format: setpoint, register and both values are required. Without due the change lasts
* until changed; without leaseUntil (a file from before ADR-011) the lease counts as lapsed.
* otherApplied only a lost reply sets.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
private record Stored(@JsonProperty(required = true) String setpoint,
@JsonProperty(required = true) int register,
@JsonProperty(required = true) int originalValue,
@JsonProperty(required = true) int appliedValue,
@JsonProperty(required = true) Instant due,
Instant due,
Instant leaseUntil,
Integer otherApplied) {
static Stored of(Restore r) {
return new Stored(r.setpoint(), r.register(), r.originalValue(), r.appliedValue(), r.due(),
r.otherApplied());
r.leaseUntil(), r.otherApplied());
}
Restore restore() throws IOException {
if (setpoint == null || due == null) {
throw new IOException("restore without setpoint or due time");
if (setpoint == null) {
throw new IOException("restore without setpoint");
}
return new Restore(setpoint, register, originalValue, appliedValue, due, otherApplied);
return new Restore(setpoint, register, originalValue, appliedValue, due,
leaseUntil == null ? Instant.EPOCH : leaseUntil, otherApplied);
}
}
}
@@ -117,13 +117,12 @@ public class CommandService implements Commands {
Integer old = null;
try {
old = registers.read(register);
var restoreAt = command.duration() == null ? null : now.plus(command.duration());
if (restoreAt != null) {
// The first original stays even if the panel changed the register since (ADR-006).
var original = previous == null ? old : previous.originalValue();
// Saved before the write, so a write never happens without its restore on disk.
remember(new Restore(setpoint.name(), register, original, command.value(), restoreAt), previous);
}
var due = command.duration() == null ? null : now.plus(command.duration());
// The first original stays even if the panel changed the register since (ADR-006).
var original = previous == null ? old : previous.originalValue();
// Saved before the write, so a write never happens without its restore on disk.
remember(new Restore(setpoint.name(), register, original, command.value(), due,
now.plus(Restore.LEASE)), previous);
lastWrite.put(register, nanoTime.getAsLong());
attempted = true;
registers.write(register, command.value());
@@ -133,11 +132,8 @@ public class CommandService implements Commands {
return failedWrite(command, setpoint, old, previous, "read back " + actual + ", expected "
+ command.value());
}
if (restoreAt == null && restores.containsKey(register)) {
forget(register);
}
audit(setpoint.name(), setpoint.register(), command.requestedBy(), "APPLIED", old, command.value());
return new CommandResult(Status.APPLIED, "applied", restoreAt);
return new CommandResult(Status.APPLIED, "applied", due);
} catch (IOException e) {
return attempted ? failedWrite(command, setpoint, old, previous, e.getMessage())
: failed(command, setpoint, old, e.getMessage());
@@ -148,12 +144,12 @@ public class CommandService implements Commands {
}
/**
* The first boost's restore must survive a failed second one. The failed write may still have
* The first change's restore must survive a failed second one. The failed write may still have
* landed (a lost reply), so the register can hold either value and both count as ours.
*/
private CommandResult failedWrite(Command command, Setpoint setpoint, Integer old, Restore previous,
String message) {
if (previous != null && command.duration() != null) {
if (previous != null) {
restores.put(previous.register(), previous.alsoApplied(command.value()));
persist();
}
@@ -166,7 +162,29 @@ public class CommandService implements Commands {
}
/**
* Runs the restores whose time has come, oldest first. One that fails stays pending and is
* Extends the lease of every restore that has not reached its run time yet (ADR-011); one that
* is due, lapsed or failing is never revived. Returns how many leases were extended.
*/
@Override
public int renewLeases() {
lock.lock();
try {
var now = clock.instant();
var renewed = restores.values().stream()
.filter(restore -> restore.runAt().isAfter(now))
.toList();
renewed.forEach(restore -> restores.put(restore.register(), restore.leasedUntil(now.plus(Restore.LEASE))));
if (!renewed.isEmpty()) {
persist();
}
return renewed.size();
} finally {
lock.unlock();
}
}
/**
* Runs the restores whose time has come (due or lease lapsed), oldest first. One that fails stays pending and is
* retried after a growing wait; the rate limit of S4 holds for restores too.
*/
public void runDueRestores() {
@@ -177,9 +195,9 @@ public class CommandService implements Commands {
}
var now = clock.instant();
restores.values().stream()
.filter(restore -> !restore.due().isAfter(now))
.filter(restore -> !restore.runAt().isAfter(now))
.filter(restore -> !isWaiting(restore))
.sorted(Comparator.comparing(Restore::due))
.sorted(Comparator.comparing(Restore::runAt))
.toList()
.forEach(this::tryRestore);
} finally {
@@ -12,6 +12,9 @@ public interface Commands {
CommandResult apply(Command command);
/** Keeps the live restores alive (ADR-011); returns how many leases were extended. */
int renewLeases();
List<Setpoint> setpoints();
List<Restore> pendingRestores();
@@ -15,6 +15,11 @@ public record DisabledCommands(String reason) implements Commands {
throw new CommandsUnavailableException(reason);
}
@Override
public int renewLeases() {
throw new CommandsUnavailableException(reason);
}
@Override
public List<Setpoint> setpoints() {
throw new CommandsUnavailableException(reason);
@@ -50,6 +50,11 @@ public class RestoreScheduler implements AutoCloseable {
return inner.apply(command);
}
@Override
public int renewLeases() {
return inner.renewLeases();
}
@Override
public List<Setpoint> setpoints() {
return inner.setpoints();
@@ -2,19 +2,31 @@ package home.gateway.domain;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Duration;
import java.time.Instant;
/**
* A pending timed restore (ADR-006): put {@code originalValue} back at {@code due}, but only if the
* register still holds {@code appliedValue}; otherwise someone changed it and we leave it.
* {@code otherApplied} is set when a later boost failed after its write may have landed (a lost
* reply): the register then holds either value and both are ours.
* A pending restore (ADR-006, ADR-011): put {@code originalValue} back, but only if the register
* still holds {@code appliedValue}; otherwise someone changed it and we leave it.
* It runs at {@link #runAt()}: {@code due}, where a change is timed (null: until changed), or the
* end of the lease, whichever comes first. The backend keeps the lease alive; a lost backend or
* tunnel lets it lapse. {@code otherApplied} is set when a later boost failed after its write may
* have landed (a lost reply): the register then holds either value and both are ours.
*/
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due,
public record Restore(String setpoint, int register, int originalValue, int appliedValue,
@JsonInclude(JsonInclude.Include.NON_NULL) Instant due, Instant leaseUntil,
@JsonInclude(JsonInclude.Include.NON_NULL) Integer otherApplied) {
public Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
this(setpoint, register, originalValue, appliedValue, due, null);
/** How long a change lasts without a renewal. */
public static final Duration LEASE = Duration.ofMinutes(15);
public Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due,
Instant leaseUntil) {
this(setpoint, register, originalValue, appliedValue, due, leaseUntil, null);
}
public Instant runAt() {
return due == null || leaseUntil.isBefore(due) ? leaseUntil : due;
}
public boolean isOurs(int current) {
@@ -22,6 +34,10 @@ public record Restore(String setpoint, int register, int originalValue, int appl
}
public Restore alsoApplied(int value) {
return new Restore(setpoint, register, originalValue, appliedValue, due, value);
return new Restore(setpoint, register, originalValue, appliedValue, due, leaseUntil, value);
}
public Restore leasedUntil(Instant newLeaseUntil) {
return new Restore(setpoint, register, originalValue, appliedValue, due, newLeaseUntil, otherApplied);
}
}
@@ -37,6 +37,12 @@ class DeyeDeviceTest {
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));
registers.putAll(Map.of(500, 2, 540, 1250, 541, 1406, 591, 65536 - 5, 609, 4995,
636, 1388));
registers.putAll(Map.of(676, 5493, 677, 13, 678, 5789, 679, 9));
registers.putAll(Map.of(516, 4537, 518, 1621, 522, 7150, 523, 2, 524, 44780,
525, 2));
registers.putAll(Map.of(527, 31750, 528, 2, 534, 48834, 535, 2));
}
@Override
@@ -47,7 +53,7 @@ class DeyeDeviceTest {
}
int[] words = new int[count];
for (int i = 0; i < count; i++) {
words[i] = registers.get(address + i);
words[i] = registers.getOrDefault(address + i, 0);
}
return words;
}
@@ -94,7 +100,35 @@ class DeyeDeviceTest {
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());
assertEquals(39, fields.size());
}
@Test
void decodesTheMoreRegistersVerifiedOnTheLiveInverter() throws IOException {
Map<String, Double> fields = onlyReading().fields();
assertEquals(2.0, fields.get("run_state"));
assertEquals(25.0, fields.get("inverter_temperature_c"), 1e-9);
assertEquals(40.6, fields.get("heatsink_temperature_c"), 1e-9);
assertEquals(-0.05, fields.get("battery_current_a"), 1e-9);
assertEquals(49.95, fields.get("grid_frequency_hz"), 1e-9);
assertEquals(1388.0, fields.get("inverter_power_w"));
assertEquals(549.3, fields.get("pv1_voltage_v"), 1e-9);
assertEquals(1.3, fields.get("pv1_current_a"), 1e-9);
assertEquals(578.9, fields.get("pv2_voltage_v"), 1e-9);
assertEquals(0.9, fields.get("pv2_current_a"), 1e-9);
}
@Test
void joinsThe32BitTotalsLowWordFirst() throws IOException {
Map<String, Double> fields = onlyReading().fields();
assertEquals(453.7, fields.get("battery_charge_total_kwh"), 1e-9);
assertEquals(162.1, fields.get("battery_discharge_total_kwh"), 1e-9);
assertEquals(13822.2, fields.get("grid_bought_total_kwh"), 1e-9);
assertEquals(17585.2, fields.get("grid_sold_total_kwh"), 1e-9);
assertEquals(16282.2, fields.get("load_total_kwh"), 1e-9);
assertEquals(17990.6, fields.get("pv_total_kwh"), 1e-9);
}
@Test
@@ -137,8 +171,8 @@ class DeyeDeviceTest {
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);
assertEquals(List.of("1:500+1", "1:514+22", "1:540+2", "1:586+6", "1:598+3", "1:609+1",
"1:622+4", "1:636+1", "1:650+4", "1:672+8"), inverter.requests);
}
@Test
@@ -68,6 +68,7 @@ class CommandsEndpointTest {
private final Snapshot snapshot = new Snapshot();
private final List<Command> received = new ArrayList<>();
private CommandResult nextResult;
private int renewals;
private GatewayHttpServer server;
private final Commands commands = new Commands() {
@@ -77,6 +78,12 @@ class CommandsEndpointTest {
return nextResult;
}
@Override
public int renewLeases() {
renewals++;
return 2;
}
@Override
public List<Setpoint> setpoints() {
return List.of(new Setpoint("co_economy", 4131, 5, 25, 1),
@@ -87,7 +94,8 @@ class CommandsEndpointTest {
@Override
public List<Restore> pendingRestores() {
return List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z")));
return List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"),
Instant.parse("2026-10-24T10:15:00Z")));
}
};
@@ -104,7 +112,12 @@ class CommandsEndpointTest {
}
private HttpResponse<String> send(String method, String body, String... headers) throws Exception {
var builder = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/commands"))
return sendTo("/commands", method, body, headers);
}
private HttpResponse<String> sendTo(String path, String method, String body, String... headers)
throws Exception {
var builder = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
if (body != null) {
@@ -256,6 +269,43 @@ class CommandsEndpointTest {
assertEquals(exampleJson("setpoints.json"), json.readTree(response.body()));
}
@Test
void renewExtendsTheLeasesAndReportsHowMany() throws Exception {
var response = sendTo("/commands/renew", "POST", null, "Authorization", "Bearer " + BEARER);
assertEquals(200, response.statusCode());
assertEquals(2, json.readTree(response.body()).get("renewed").asInt());
assertEquals(1, renewals);
}
@Test
void renewNeedsTheTokenAndOnlyAcceptsPost() throws Exception {
assertEquals(401, sendTo("/commands/renew", "POST", null).statusCode());
now.updateAndGet(time -> time.plus(Duration.ofMinutes(2)));
assertEquals(405, sendTo("/commands/renew", "GET", null, "Authorization", "Bearer " + BEARER).statusCode());
assertEquals(0, renewals);
}
@Test
void failedTokensOnRenewCountForTheSameThrottle() throws Exception {
for (int i = 0; i < 5; i++) {
assertEquals(401, sendTo("/commands/renew", "POST", null).statusCode());
}
assertEquals(429, sendTo("/commands/renew", "POST", null, "Authorization", "Bearer " + BEARER).statusCode());
assertEquals(0, renewals);
}
@Test
void renewIs503WhenTheWritePathIsUnavailable() throws Exception {
server.close();
server = new GatewayHttpServer(snapshot, clock, new InetSocketAddress(0), Set.of());
server.serveCommands(new DisabledCommands("restore file was corrupt"), BEARER);
server.start();
assertEquals(503, sendTo("/commands/renew", "POST", null, "Authorization", "Bearer " + BEARER).statusCode());
}
@Test
void getHasNullValuesBeforeTheFirstPoll() throws Exception {
var body = json.readTree(authorized("GET", null).body());
@@ -29,6 +29,7 @@ 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;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -118,6 +119,7 @@ class GatewayHttpServerTest {
assertEquals(200, response.statusCode());
var body = json.readTree(response.body());
assertEquals("UP", body.get("status").asText());
assertFalse(body.get("version").asText().isBlank());
assertEquals("UP", device(body, "pump").get("status").asText());
assertEquals("2026-10-24T10:00:00Z", device(body, "pump").get("lastSuccess").asText());
}
@@ -32,7 +32,7 @@ class HouseMeterDeviceTest {
List<Reading> readings = new HouseMeterDevice(meter).read(NOW);
assertEquals(1, readings.size());
assertEquals(2, readings.size());
Reading reading = readings.getFirst();
assertEquals("electricity", reading.database());
assertEquals("PRAD2", reading.measurement());
@@ -44,13 +44,58 @@ class HouseMeterDeviceTest {
}
@Test
void readsFourBlocksOfUnitOneOneAfterAnother() throws Exception {
void reportsThePhaseEnergyCountersAsASecondPointOfTheSamePoll() throws Exception {
FakeMeter meter = new FakeMeter();
meter.setWords(0x015a, 1.5f);
meter.setWords(0x0164, 2.5f);
meter.setWords(0x017c, 3.5f);
meter.setWords(0x016c, Float.NaN);
Reading reading = new HouseMeterDevice(meter).read(NOW).get(1);
assertEquals("electricity", reading.database());
assertEquals("house_meter_phases", reading.measurement());
assertEquals(Map.of("location", "dom-szczepancowa"), reading.tags());
assertEquals(NOW, reading.time());
assertEquals(1.5, reading.fields().get("l1_import_energy_active_kwh"));
assertEquals(2.5, reading.fields().get("l3_export_energy_active_kwh"));
assertEquals(3.5, reading.fields().get("l3_total_energy_reactive_kvarh"));
assertFalse(reading.fields().containsKey("l1_import_energy_reactive_kvarh"));
assertEquals(17, reading.fields().size());
}
@Test
void keepsPrad2WhenThePhaseEnergyRequestFails() throws Exception {
FakeMeter meter = new FakeMeter();
meter.set("frequency", 50f);
meter.failAt = 0x015a;
List<Reading> readings = new HouseMeterDevice(meter).read(NOW);
assertEquals(1, readings.size());
assertEquals("PRAD2", readings.getFirst().measurement());
assertEquals(50.0, readings.getFirst().fields().get("frequency"));
}
@Test
void keepsPrad2WhenThePhaseEnergyBlockComesBackShort() throws Exception {
FakeMeter meter = new FakeMeter();
meter.set("frequency", 50f);
meter.shortAt = 0x015a;
List<Reading> readings = new HouseMeterDevice(meter).read(NOW);
assertEquals(List.of("PRAD2"), readings.stream().map(Reading::measurement).toList());
}
@Test
void readsFourBlocksOfPrad2AndThePhaseEnergyBlockOfUnitOneOneAfterAnother() 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);
assertEquals(List.of("1:0+80", "1:80+28", "1:200+70", "1:334+12", "1:346+36"), meter.requests);
}
@Test
@@ -142,9 +187,13 @@ class HouseMeterDeviceTest {
void set(String name, float value) {
Sdm630Registers.Register register = Sdm630Registers.INPUT.stream()
.filter(r -> r.name().equals(name)).findFirst().orElseThrow();
setWords(register.address(), value);
}
void setWords(int address, float value) {
int bits = Float.floatToRawIntBits(value);
registers[register.address()] = bits >>> 16;
registers[register.address() + 1] = bits & 0xFFFF;
registers[address] = bits >>> 16;
registers[address + 1] = bits & 0xFFFF;
}
void fillWith(float value) {
@@ -8,16 +8,24 @@ import java.net.ServerSocket;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/** Local Modbus TCP fake answering FC3, FC4 and FC6; unknown addresses get exception code 2. */
/**
* Local Modbus fake answering FC3, FC4 and FC6; unknown addresses get exception code 2. It speaks
* Modbus TCP, or RTU frames over TCP when made with {@link #rtuOverTcp()}.
*/
public final class MockModbusServer implements Closeable {
private record Request(int transactionId, int unitId, byte[] pdu) {
}
private final ServerSocket server;
private final boolean rtu;
private final Map<Integer, Integer> holding = new ConcurrentHashMap<>();
private final Map<Integer, Integer> input = new ConcurrentHashMap<>();
private final AtomicInteger connections = new AtomicInteger();
@@ -26,15 +34,26 @@ public final class MockModbusServer implements Closeable {
private volatile Duration responseDelay = Duration.ZERO;
private volatile boolean dropNextRequest;
private volatile boolean wrongTransactionId;
private volatile boolean wrongCrc;
private volatile boolean wrongUnit;
private volatile boolean overlapped;
private volatile boolean wrongWriteEcho;
private volatile int writeException;
public MockModbusServer() throws IOException {
this(false);
}
private MockModbusServer(boolean rtu) throws IOException {
this.rtu = rtu;
server = new ServerSocket(0, 50, InetAddress.getLoopbackAddress());
Thread.ofVirtual().start(this::acceptLoop);
}
static MockModbusServer rtuOverTcp() throws IOException {
return new MockModbusServer(true);
}
public int port() {
return server.getLocalPort();
}
@@ -90,6 +109,16 @@ public final class MockModbusServer implements Closeable {
wrongTransactionId = true;
}
/** RTU only: answers the next request with a CRC that does not match. */
void answerWithWrongCrc() {
wrongCrc = true;
}
/** Answers the next request as if another unit had sent it. */
void answerFromAnotherUnit() {
wrongUnit = true;
}
@Override
public void close() throws IOException {
server.close();
@@ -112,15 +141,13 @@ public final class MockModbusServer implements Closeable {
try (client) {
DataInputStream in = new DataInputStream(client.getInputStream());
while (true) {
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(7));
if (header.remaining() < 7) {
Request request = rtu ? readRtu(in) : readMbap(in);
if (request == null) {
return;
}
int transactionId = header.getShort() & 0xFFFF;
header.getShort();
int length = header.getShort() & 0xFFFF;
int unitId = header.get() & 0xFF;
byte[] pdu = in.readNBytes(length - 1);
int transactionId = request.transactionId();
int unitId = request.unitId();
byte[] pdu = request.pdu();
if (dropNextRequest) {
dropNextRequest = false;
return;
@@ -131,7 +158,12 @@ public final class MockModbusServer implements Closeable {
wrongTransactionId = false;
transactionId++;
}
client.getOutputStream().write(frame(transactionId, unitId, answer(unitId, pdu)));
byte[] answer = answer(unitId, pdu);
if (wrongUnit) {
wrongUnit = false;
unitId++;
}
client.getOutputStream().write(rtu ? rtuFrame(unitId, answer) : frame(transactionId, unitId, answer));
}
} catch (IOException ignored) {
// the client went away, which is a normal end of a test
@@ -140,6 +172,43 @@ public final class MockModbusServer implements Closeable {
}
}
private static Request readMbap(DataInputStream in) throws IOException {
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(7));
if (header.remaining() < 7) {
return null;
}
int transactionId = header.getShort() & 0xFFFF;
header.getShort();
int length = header.getShort() & 0xFFFF;
int unitId = header.get() & 0xFF;
return new Request(transactionId, unitId, in.readNBytes(length - 1));
}
/** Every read request (FC3, FC4) is unit, function, four bytes and the CRC. */
private static Request readRtu(DataInputStream in) throws IOException {
byte[] frame = in.readNBytes(8);
if (frame.length < 8) {
return null;
}
int crc = RtuFraming.crc(frame, 6);
if ((frame[6] & 0xFF) != (crc & 0xFF) || (frame[7] & 0xFF) != crc >>> 8) {
throw new IOException("request with a wrong CRC");
}
return new Request(0, frame[0] & 0xFF, Arrays.copyOfRange(frame, 1, 6));
}
private byte[] rtuFrame(int unitId, byte[] pdu) {
byte[] frame = ByteBuffer.allocate(1 + pdu.length + 2).put((byte) unitId).put(pdu).array();
int crc = RtuFraming.crc(frame, 1 + pdu.length);
if (wrongCrc) {
wrongCrc = false;
crc ^= 1;
}
frame[frame.length - 2] = (byte) crc;
frame[frame.length - 1] = (byte) (crc >>> 8);
return frame;
}
private void pause() {
try {
Thread.sleep(responseDelay);
@@ -0,0 +1,70 @@
package home.gateway.adapters.modbus;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import java.time.Duration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/** The house meter's RS485 converter: RTU frames with their CRC over TCP, no MBAP header. */
class RtuOverTcpTransportTest {
private static final byte[] READ_INPUT_0 = {4, 0, 0, 0, 2};
private MockModbusServer server;
private TcpTransport transport;
@BeforeEach
void start() throws IOException {
server = MockModbusServer.rtuOverTcp();
server.input(0, 0x436B);
server.input(1, 0xA8F6);
transport = TcpTransport.rtuOverTcp(new TcpTransport.Settings(
"localhost", server.port(), Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO));
}
@AfterEach
void stop() throws IOException {
transport.close();
server.close();
}
@Test
void framesTheRequestWithTheModbusCrc() {
// The specification's example: read 10 holding registers from unit 1 ends in C5 CD.
assertArrayEquals(new byte[] {1, 3, 0, 0, 0, 10, (byte) 0xC5, (byte) 0xCD},
new RtuFraming().frame(1, new byte[] {3, 0, 0, 0, 10}));
}
@Test
void readsInputRegisters() throws IOException {
int[] words = new PduModbusClient(transport).readInputRegisters(1, 0, 2);
assertEquals(235.66f, Float.intBitsToFloat(words[0] << 16 | words[1]), 0.005f);
}
@Test
void returnsExceptionPduAsIs() throws IOException {
assertArrayEquals(new byte[] {(byte) 0x84, 2}, transport.exchange(1, new byte[] {4, 0, 99, 0, 1}));
}
@Test
void rejectsAWrongCrcAndReconnectsAfterwards() throws IOException {
server.answerWithWrongCrc();
assertThrows(IOException.class, () -> transport.exchange(1, READ_INPUT_0));
assertEquals(6, transport.exchange(1, READ_INPUT_0).length);
assertEquals(2, server.connections());
}
@Test
void rejectsAnAnswerFromAnotherUnit() {
server.answerFromAnotherUnit();
assertThrows(IOException.class, () -> transport.exchange(1, READ_INPUT_0));
}
}
@@ -66,7 +66,7 @@ class TcpTransportTest {
transport.close();
transport = new TcpTransport(new TcpTransport.Settings(
"localhost", server.port(), Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO),
Duration.ofMillis(100));
new MbapFraming(), Duration.ofMillis(100));
transport.exchange(1, READ_HOLDING_10);
transport.exchange(1, READ_HOLDING_10);
@@ -21,7 +21,7 @@ class JsonRestoreStoreTest {
Path dir;
private final Restore restore = new Restore("cwu_comfort", 4144, 450, 500,
Instant.parse("2026-10-24T11:00:00Z"));
Instant.parse("2026-10-24T11:00:00Z"), Instant.parse("2026-10-24T10:15:00Z"));
@Test
void missingFileIsEmpty() throws IOException {
@@ -70,8 +70,7 @@ class JsonRestoreStoreTest {
var cases = List.of("{not json", "", "garbage \u0000\u0001", "null", "{}", "[null]",
"[{\"setpoint\":\"x\"}]", restoreJson("4144", "\"5\""), restoreJson("null", "5"),
restoreJson("4144", "5").replace("}]", ",\"extra\":1}]"),
restoreJson("4144", "5").replace("\"x\"", "null"),
restoreJson("4144", "5").replace("\"2026-10-24T11:00:00Z\"", "null"));
restoreJson("4144", "5").replace("\"x\"", "null"));
for (var content : cases) {
var file = dir.resolve("restores.json");
Files.writeString(file, content);
@@ -90,7 +89,8 @@ class JsonRestoreStoreTest {
@Test
void aLostReplyValueSurvivesSaveAndLoad() throws IOException {
var file = dir.resolve("restores.json");
var restore = new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T11:00:00Z")).alsoApplied(47);
var restore = new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T11:00:00Z"),
Instant.parse("2026-10-24T10:15:00Z")).alsoApplied(47);
new JsonRestoreStore(file).save(List.of(restore));
@@ -105,6 +105,28 @@ class JsonRestoreStoreTest {
assertEquals(1, new JsonRestoreStore(file).load().size());
}
@Test
void aFileFromBeforeLeasesLoadsWithALapsedLease() throws IOException {
var file = dir.resolve("restores.json");
Files.writeString(file, restoreJson("4144", "450"));
var loaded = new JsonRestoreStore(file).load().getFirst();
assertEquals(Instant.EPOCH, loaded.leaseUntil());
assertEquals(Instant.parse("2026-10-24T11:00:00Z"), loaded.due());
}
@Test
void anUntilChangedRestoreHasNoDueInTheFile() throws IOException {
var file = dir.resolve("restores.json");
var untilChanged = new Restore("cwu_comfort", 4144, 450, 100, null, Instant.parse("2026-10-24T10:15:00Z"));
new JsonRestoreStore(file).save(List.of(untilChanged));
assertFalse(Files.readString(file).contains("due"));
assertEquals(List.of(untilChanged), new JsonRestoreStore(file).load());
}
@Test
void afterTheRenameTheNextLoadIsEmpty() throws IOException {
var file = dir.resolve("restores.json");
@@ -194,7 +194,7 @@ class CommandServiceTest {
var result = service().apply(command("cwu_comfort", 50, HOUR));
assertEquals(T0.plus(HOUR), result.restoreAt());
var expected = new Restore("cwu_comfort", 4144, 45, 50, T0.plus(HOUR));
var expected = new Restore("cwu_comfort", 4144, 45, 50, T0.plus(HOUR), T0.plus(Restore.LEASE));
assertEquals(List.of(expected), stored);
}
@@ -206,21 +206,23 @@ class CommandServiceTest {
service.apply(command("cwu_comfort", 48, Duration.ofHours(2)));
var expected = new Restore("cwu_comfort", 4144, 45, 48, clock.instant().plus(Duration.ofHours(2)));
var expected = new Restore("cwu_comfort", 4144, 45, 48, clock.instant().plus(Duration.ofHours(2)),
clock.instant().plus(Restore.LEASE));
assertEquals(List.of(expected), service.pendingRestores());
assertEquals(List.of(expected), stored);
}
@Test
void lastingCommandCancelsAPendingRestore() throws IOException {
void lastingCommandKeepsTheOriginalAndDropsTheEndTime() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, HOUR));
clock.advance(Duration.ofMinutes(5));
service.apply(command("cwu_comfort", 40, null));
assertTrue(service.pendingRestores().isEmpty());
assertTrue(stored.isEmpty());
var expected = new Restore("cwu_comfort", 4144, 45, 40, null, clock.instant().plus(Restore.LEASE));
assertEquals(List.of(expected), service.pendingRestores());
assertEquals(List.of(expected), stored);
}
@Test
@@ -279,7 +281,7 @@ class CommandServiceTest {
@Test
void doesNothingBeforeDue() throws IOException {
var service = boosted();
clock.advance(Duration.ofMinutes(59));
advanceRenewing(service, Duration.ofMinutes(59));
service.runDueRestores();
@@ -382,7 +384,7 @@ class CommandServiceTest {
var first = service();
first.apply(command("cwu_comfort", 50, HOUR));
first.apply(command("co_comfort", 25, Duration.ofHours(3)));
clock.advance(Duration.ofHours(2));
advanceRenewing(first, Duration.ofHours(2));
var restarted = service();
try (var scheduler = new RestoreScheduler(restarted)) {
@@ -461,7 +463,7 @@ class CommandServiceTest {
}
private void assertFirstBoostStillPendingAcceptingSecond(CommandService service) {
var expected = new Restore("cwu_comfort", 4144, 45, 50, T0.plus(HOUR), 48);
var expected = new Restore("cwu_comfort", 4144, 45, 50, T0.plus(HOUR), T0.plus(Restore.LEASE), 48);
assertEquals(List.of(expected), service.pendingRestores());
assertEquals(List.of(expected), stored);
}
@@ -525,11 +527,11 @@ class CommandServiceTest {
@Test
void badStoredRestoresAreDroppedOnLoadAndNeverWritten() throws IOException {
var good = new Restore("co_comfort", 4132, 20, 25, T0.minus(HOUR));
var good = new Restore("co_comfort", 4132, 20, 25, T0.minus(HOUR), T0.minus(HOUR));
stored.addAll(List.of(
new Restore("cwu_comfort", 0, 0, 50, T0.minus(HOUR)),
new Restore("gone", 4145, 45, 50, T0.minus(HOUR)),
new Restore("cwu_comfort", 4144, 45, 99, T0.minus(HOUR), 20),
new Restore("cwu_comfort", 0, 0, 50, T0.minus(HOUR), T0.minus(HOUR)),
new Restore("gone", 4145, 45, 50, T0.minus(HOUR), T0.minus(HOUR)),
new Restore("cwu_comfort", 4144, 45, 99, T0.minus(HOUR), T0.minus(HOUR), 20),
good));
registers.values.put(4132, 25);
@@ -690,19 +692,155 @@ class CommandServiceTest {
}
@Test
void aFailedSaveAfterALastingCommandIsRetriedOnTheNextTick() throws IOException {
void aFailedSaveAfterRenewingIsRetriedOnTheNextTick() throws IOException {
var service = boosted();
clock.advance(Duration.ofMinutes(2));
clock.advance(Duration.ofMinutes(5));
storeFails = true;
assertEquals(Status.APPLIED, service.apply(command("cwu_comfort", 40, null)).status());
assertEquals(1, stored.size());
assertEquals(1, service.renewLeases());
assertEquals(T0.plus(Restore.LEASE), stored.getFirst().leaseUntil());
storeFails = false;
service.runDueRestores();
assertEquals(clock.instant().plus(Restore.LEASE), stored.getFirst().leaseUntil());
}
private void advanceRenewing(CommandService service, Duration total) {
for (var left = total; !left.isZero(); ) {
var step = left.compareTo(Duration.ofMinutes(5)) < 0 ? left : Duration.ofMinutes(5);
clock.advance(step);
service.renewLeases();
left = left.minus(step);
}
}
@Test
void aLapsedLeaseRestoresAnUntilChangedChange() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, null));
clock.advance(Restore.LEASE.minusSeconds(1));
service.runDueRestores();
assertEquals(50, registers.values.get(4144));
clock.advance(Duration.ofSeconds(1));
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
assertTrue(service.pendingRestores().isEmpty());
assertTrue(stored.isEmpty());
}
@Test
void aLapsedLeaseBeatsADistantEndTime() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, Duration.ofHours(10)));
clock.advance(Restore.LEASE);
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
}
@Test
void renewingKeepsAChangeBeyondTheLeaseAndSavesTheNewLease() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, null));
clock.advance(Duration.ofMinutes(10));
assertEquals(1, service.renewLeases());
assertEquals(clock.instant().plus(Restore.LEASE), stored.getFirst().leaseUntil());
clock.advance(Duration.ofMinutes(10));
service.runDueRestores();
assertEquals(50, registers.values.get(4144));
assertEquals(1, service.pendingRestores().size());
}
@Test
void renewingNeverPushesARestoreBeyondItsEndTime() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, Duration.ofMinutes(20)));
advanceRenewing(service, Duration.ofMinutes(19));
assertEquals(50, registers.values.get(4144));
clock.advance(Duration.ofMinutes(1));
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
}
@Test
void renewingDoesNotReviveALapsedLeaseBeforeItsRestoreRan() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, null));
clock.advance(Restore.LEASE);
assertEquals(0, service.renewLeases());
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
}
@Test
void renewingDoesNotReviveARestoreThatKeepsFailing() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, null));
clock.advance(Restore.LEASE);
registers.failWrites = true;
service.runDueRestores();
var lease = service.pendingRestores().getFirst().leaseUntil();
assertEquals(0, service.renewLeases());
assertEquals(lease, service.pendingRestores().getFirst().leaseUntil());
registers.failWrites = false;
clock.advance(Duration.ofMinutes(1));
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
}
@Test
void aFailedWriteOnLapseGoesThroughTheBackoff() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, null));
clock.advance(Restore.LEASE);
registers.failWrites = true;
for (var wait : List.of(1, 2, 5)) {
var writes = registers.writes;
service.runDueRestores();
assertEquals(writes + 1, registers.writes);
clock.advance(Duration.ofMinutes(wait).minusSeconds(1));
service.runDueRestores();
assertEquals(writes + 1, registers.writes);
clock.advance(Duration.ofSeconds(1));
}
assertEquals(3, audit.stream().filter(reading -> "FAILED".equals(reading.tags().get("status"))).count());
assertEquals(1, service.pendingRestores().size());
assertEquals(50, registers.values.get(4144));
}
@Test
void aRestoreFromBeforeLeasesIsRestoredOnStart() throws IOException {
stored.add(new Restore("cwu_comfort", 4144, 45, 50, T0.plus(Duration.ofHours(5)), Instant.EPOCH));
registers.values.put(4144, 50);
var service = service();
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
assertTrue(service.pendingRestores().isEmpty());
}
@Test
void renewingWithNothingPendingChangesNothing() throws IOException {
var service = service();
assertEquals(0, service.renewLeases());
assertEquals(0, registers.writes);
}
private static final class MutableClock extends Clock {
private Instant now = T0;
private long nanos;
@@ -6,6 +6,6 @@
{ "name": "cwu_comfort", "register": 4144, "min": 5, "max": 48, "step": 1, "value": 45 }
],
"restores": [
{ "setpoint": "cwu_economy", "register": 4143, "originalValue": 8, "appliedValue": 45, "due": "2026-10-24T12:00:00Z" }
{ "setpoint": "cwu_economy", "register": 4143, "originalValue": 8, "appliedValue": 45, "due": "2026-10-24T12:00:00Z", "leaseUntil": "2026-10-24T10:15:00Z" }
]
}
+20
View File
@@ -29,6 +29,26 @@ SDM630 house meter: 645 W vs 640 W):
| 526 | today load | /10 kWh | 24.2 |
| 529 | today PV production | /10 kWh | 29.9 |
More registers verified live 2026-10-24 about 12:00 (PV running, two reads a minute apart;
every 32-bit total is low word first, /10 kWh, and the today counters are below the totals):
| Register | Meaning | Scale | Value at check |
|---|---|---|---|
| 500 | run state (2 = normal) | – | 2 |
| 516–517, 518–519 | total battery charge, discharge | /10 kWh | 453.7, 162.1 |
| 522–523, 524–525 | total grid bought, sold | /10 kWh | 13822.2, 17585.2 |
| 527–528 | total load | /10 kWh | 16282.2 |
| 534–535 | total PV (this inverter only, not the Fronius) | /10 kWh | 17990.6 |
| 540, 541 | inverter temperature, heat sink | (x − 1000) / 10 °C | 25.0, 40.6 |
| 591 | battery current, signed like 590 | /100 A | −0.05 (590: −3 W at 54.8 V) |
| 609 | grid frequency | /100 Hz | 49.95 |
| 636 | inverter output power, total (= sum of 633–635) | W, signed | 1388 |
| 676–679 | PV1 V, A, PV2 V, A | /10 | 549.3 V 1.3 A, 578.9 V 0.9 A (PV W 763, 553) |
Not used: 627–632 and 644–646 (inverter and load voltages and currents, current scale
unclear: 452 W at 232.8 V does not fit 180 as /100 A), 552–558 (alarm words, 552 = 5 while
running normally, meaning unknown), 592 and 604–607 (unknown).
The battery is configured as **backup only** (power cuts), on purpose: day and night the house
runs on PV and the grid, so a full battery with ~0 W flow is the normal state.