Compare commits

...
10 Commits
Author SHA1 Message Date
root b288c98106 Merge PR #81: feat: battery schedule and reserve from the app (ADR-013)
CI / Check (gateway, backend, frontend) (push) Successful in 1m27s
Release / Images (push) Successful in 3m2s
2026-10-24 15:55:04 +02:00
rootandClaude Opus 5.5 e5e13cedb5 docs: ADR-013 battery settings from the app, spec F8, register notes
CI / Check (gateway, backend, frontend) (push) Successful in 1m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:51:47 +02:00
rootandClaude Opus 5.5 a594512f33 feat(frontend): edit the battery reserve and schedule in the Bateria view
Reserve stepper with the API's bounds, schedule edit mode with a
confirmation summary and Polish results; hidden when not editable.
Stepper and Button move to ui.tsx for reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:31:13 +02:00
rootandClaude Opus 5.5 a5ba9e8756 feat(backend): change the battery schedule and reserve through the gateway
POST /api/battery/schedule and /api/battery/reserve with the same
guards as the other writes; /api/battery shows the reserve and whether
changes are possible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:26:58 +02:00
rootandClaude Opus 5.5 619c3ee990 feat(gateway): change the Deye battery schedule and reserve (off by default)
Token-protected /battery/schedule and /battery/reserve: allowlisted Time
of Use changes checked as a whole against a fresh reading and the live
registers, FC16 per range, read-back, audit, rate limit per register.
The reserve is an app setting kept in reserve.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:23:33 +02:00
rootandClaude Opus 5.5 8111405326 feat(gateway): write multiple holding registers (FC16)
CI / Check (gateway, backend, frontend) (push) Successful in 1m21s
Needed for the Deye's program table; works over Solarman V5 unchanged,
as the RTU frame wraps any PDU. The echo of address and count is checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:03:29 +02:00
root 965dfae644 Merge PR #80: feat: Bateria and Falowniki views in the Energia tab
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
2026-10-24 14:57:52 +02:00
rootandClaude Opus 5.5 2da7246fe4 feat(frontend): Bateria and Falowniki views in the Energia tab
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
A segmented switch (Teraz, Bateria, Falowniki) kept in the URL hash so
Back returns to the previous view; tapping the battery in the flow
diagram opens Bateria. The device list moves to Falowniki.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:55:06 +02:00
rootandClaude Opus 5.5 a4dc88cbbc feat(backend): serve the Deye battery state and settings at /api/battery
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:51:44 +02:00
rootandClaude Opus 5.5 a01c507a6e feat(gateway): read the Deye battery settings into electricity.deye_settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:49:38 +02:00
85 changed files with 4490 additions and 191 deletions

No files matched your search

+14
View File
@@ -8,6 +8,20 @@ The records in `home.backend.domain` and the examples in `src/test/resources/api
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
the three values are null (`pi-sensor-stale.json`).
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
`mode` (`touEnabled` with a Polish sentence), `schedule` (six slots, each from its start to the next one, the last wraps past midnight),
`limits`, and `capacityKwh` = capacity in Ah x a nominal 51.2 V, since the inverter reports Ah only. When stale they are null (`schedule` empty).
`/api/battery` also has `reserve` (`pct`, `floorPct`: the backup reserve of the app and the lowest the user may set) and `editable`:
true when the gateway answers its `/battery/reserve`, i.e. it has battery commands on and `GATEWAY_TOKEN` is right; else `reserve` is null
and `editable` false. `POST /api/battery/schedule` (examples `battery-schedule-request.json`, `battery-failed.json`) takes
`{"enabled"?, "slots": [{"slot": 1-6, "from"?: "HH:MM", "socPct"?, "gridCharge"?}]}` and `POST /api/battery/reserve` takes `{"pct": 60}`.
Same Origin, header and JSON rules as the other writes; `Remote-User` goes on as the requester. The gateway's verdict comes back as
`{status, message, read}`: 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon, 502 failed (`read` is what the
inverter holds), 400 bad body, 503 gateway unreachable or without battery commands (ADR-013).
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
without the two URLs.
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`.
@@ -6,6 +6,7 @@ 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.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
@@ -33,13 +34,15 @@ 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 setpoints = new SetpointService(commands(settings), PresetFile.load(settings.presetsFile()));
var commands = commands(settings);
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
return new ApiServer(settings.port(),
new HeatPumpService(gateway, clock),
new EnergyService(gateway, clock),
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock));
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
new BatteryService(gateway, commands, clock));
}
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
@@ -5,7 +5,11 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayBattery;
import home.backend.application.GatewayCommands;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
@@ -18,10 +22,11 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands {
/** Sends commands to the gateway's {@code /commands} and {@code /battery} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
@@ -29,6 +34,20 @@ public class GatewayHttpCommands implements GatewayCommands {
/** Restore now: 200 restored, 404 nothing pending, 409 changed meanwhile, 429 too soon, 502 failed. */
private static final Set<Integer> RESTORE_VERDICTS = Set.of(200, 404, 409, 429, 502);
/** Battery change: 200 applied, 422 rejected, 409 stale or changed meanwhile, 429 too soon, 502 failed. */
private static final Set<Integer> BATTERY_VERDICTS = Set.of(200, 422, 409, 429, 502);
@JsonInclude(JsonInclude.Include.NON_NULL)
private record ScheduleRequest(Boolean enabled, List<SlotRequest> slots) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
private record SlotRequest(int slot, Integer start, Integer soc, Boolean gridCharge) {
}
private record ReserveRequest(int pct) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
private record Request(String setpoint, int value, Long minutes, Instant until) {
}
@@ -47,6 +66,8 @@ public class GatewayHttpCommands implements GatewayCommands {
private final URI endpoint;
private final URI renewEndpoint;
private final URI restoreEndpoint;
private final URI reserveEndpoint;
private final URI scheduleEndpoint;
private final String token;
private final Duration timeout;
private final HttpClient http;
@@ -56,6 +77,8 @@ public class GatewayHttpCommands implements GatewayCommands {
this.endpoint = baseUri.resolve("/commands");
this.renewEndpoint = baseUri.resolve("/commands/renew");
this.restoreEndpoint = baseUri.resolve("/commands/restore");
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
this.token = token;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
@@ -90,6 +113,30 @@ public class GatewayHttpCommands implements GatewayCommands {
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
}
@Override
public Reserve reserve() throws IOException {
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
var slots = change.slots().stream().map(slot -> new SlotRequest(slot.slot(),
slot.from() == null ? null : slot.from().getHour() * 100 + slot.from().getMinute(),
slot.socPct(), slot.gridCharge())).toList();
return battery(scheduleEndpoint, new ScheduleRequest(change.enabled(), slots), requestedBy);
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
return battery(reserveEndpoint, new ReserveRequest(pct), requestedBy);
}
private BatteryResult battery(URI uri, Object body, String requestedBy) throws IOException {
var request = request(uri).header("Content-Type", "application/json").header("X-Requested-By", requestedBy)
.POST(HttpRequest.BodyPublishers.ofByteArray(JSON.writeValueAsBytes(body)));
return JSON.readValue(send(request, BATTERY_VERDICTS), BatteryResult.class);
}
private HttpRequest.Builder request() throws IOException {
return request(endpoint);
}
@@ -103,14 +150,15 @@ public class GatewayHttpCommands implements GatewayCommands {
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
try {
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
var built = request.build();
var response = http.send(built, HttpResponse.BodyHandlers.ofByteArray());
if (!accepted.contains(response.statusCode())) {
throw new IOException("gateway /commands answered " + response.statusCode());
throw new IOException("gateway " + built.uri().getPath() + " answered " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while calling gateway /commands", e);
throw new IOException("interrupted while calling the gateway", e);
}
}
}
@@ -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.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
@@ -16,6 +17,8 @@ import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.PresetResult;
import home.backend.domain.RestoreResult;
@@ -42,6 +45,9 @@ public class ApiServer {
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
private record ReserveRequest(Integer pct) {
}
private record SetpointRequest(String setpoint, Integer value, Double hours) {
}
@@ -58,6 +64,7 @@ public class ApiServer {
}
private static final String UNREACHABLE = "gateway unreachable";
private static final String UNAVAILABLE = "battery changes are not available: gateway unreachable or not enabled";
private static final String WRITE_MARKER = "home-pump";
private static final String PRESETS_PATH = "/api/presets/";
private static final String SETPOINTS_PATH = "/api/setpoints/";
@@ -79,7 +86,8 @@ public class ApiServer {
/** Binds the port now; 0 picks a free one. */
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints, PiSensorService piSensor) throws IOException {
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
BatteryService battery) throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(executor);
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
@@ -91,6 +99,11 @@ 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/battery", query -> battery.current());
route("/api/battery/schedule", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
api("/api/pi-sensor", query -> piSensor.current());
api("/api/health", query -> health.current());
route("/api/history", exchange -> handleHistory(exchange, history));
@@ -208,6 +221,37 @@ public class ApiServer {
}
}
private static void postSchedule(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
try {
var change = JSON.readValue(body, BatteryChange.class);
if (change == null) {
throw new IllegalArgumentException("a change is required");
}
var result = battery.changeSchedule(change, user(exchange));
send(exchange, batteryHttpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"enabled\"?, \"slots\": [{\"slot\", \"from\"?: \"HH:MM\", "
+ "\"socPct\"?, \"gridCharge\"?}]}"));
} catch (IOException e) {
send(exchange, 503, error(UNAVAILABLE));
}
}
private static void postReserve(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
try {
var request = JSON.readValue(body, ReserveRequest.class);
if (request == null || request.pct() == null) {
throw new IllegalArgumentException("pct is required");
}
var result = battery.changeReserve(request.pct(), user(exchange));
send(exchange, batteryHttpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"pct\"}"));
} catch (IOException e) {
send(exchange, 503, error(UNAVAILABLE));
}
}
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
throws IOException {
try {
@@ -247,6 +291,16 @@ public class ApiServer {
};
}
private static int batteryHttpStatus(BatteryResult.Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case STALE -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
return switch (outcome) {
case RESTORED -> 200;
@@ -0,0 +1,109 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.BatteryView.Energy;
import home.backend.domain.BatteryView.Limits;
import home.backend.domain.BatteryView.Mode;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.BatteryView.Slot;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
import static home.backend.application.SnapshotFields.on;
import static home.backend.application.SnapshotFields.whole;
/** F7: the Deye's battery now, and how it is set up. */
public class BatteryService {
/** The settings are read every 10 minutes; three missed reads and they are not current. */
private static final Duration SETTINGS_MAX_AGE = Duration.ofMinutes(30);
private static final int SLOTS = 6;
/** The nominal voltage of a 16-cell lithium pack; the Deye reports capacity in Ah only. */
private static final double NOMINAL_VOLTS = 51.2;
private final GatewayClient gateway;
private final GatewayBattery battery;
private final Clock clock;
public BatteryService(GatewayClient gateway, GatewayBattery battery, Clock clock) {
this.gateway = gateway;
this.battery = battery;
this.clock = clock;
}
public BatteryResult changeSchedule(BatteryChange change, String user) throws IOException {
return battery.changeSchedule(change, user);
}
public BatteryResult changeReserve(int pct, String user) throws IOException {
return battery.changeReserve(pct, user);
}
/** Asked on its own: a gateway without battery commands still serves the live view. */
private Reserve reserve() {
try {
return battery.reserve();
} catch (IOException e) {
return null;
}
}
public BatteryView current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
Live settings = SnapshotFields.live(snapshot, "deye-settings", "deye_settings", clock.instant(),
SETTINGS_MAX_AGE);
Map<String, Double> d = deye.fields();
Map<String, Double> s = settings.fields();
boolean settingsStale = settings.source().stale();
Reserve reserve = reserve();
return new BatteryView(deye.source().time(), deye.source().stale(),
d.get("battery_soc_pct"), d.get("battery_power_w"), d.get("battery_voltage_v"),
d.get("battery_current_a"), d.get("battery_temperature_c"), capacityKwh(s),
new Energy(d.get("battery_charge_today_kwh"), d.get("battery_discharge_today_kwh")),
new Energy(d.get("battery_charge_total_kwh"), d.get("battery_discharge_total_kwh")),
settingsStale ? null : mode(s), settingsStale ? List.of() : schedule(s),
settingsStale ? null : limits(s), settings.source().time(), settingsStale, reserve, reserve != null);
}
private static Double capacityKwh(Map<String, Double> settings) {
Double ah = settings.get("battery_capacity_ah");
return ah == null ? null : Math.round(ah * NOMINAL_VOLTS) / 1000.0;
}
private static Mode mode(Map<String, Double> s) {
boolean tou = on(s, "tou_enabled");
return new Mode(tou, tou ? "Harmonogram: bateria pracuje według przedziałów"
: "Rezerwa na awarię: bateria nie rozładowuje się na co dzień");
}
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first. */
private static List<Slot> schedule(Map<String, Double> s) {
return IntStream.rangeClosed(1, SLOTS).mapToObj(n -> {
String prefix = "prog" + n + "_";
String next = "prog" + (n % SLOTS + 1) + "_start_hhmm";
return new Slot(time(whole(s, prefix + "start_hhmm")), time(whole(s, next)),
whole(s, prefix + "power_w"), whole(s, prefix + "soc_pct"), on(s, prefix + "grid_charge"));
}).toList();
}
private static String time(int hhmm) {
return "%02d:%02d".formatted(hhmm / 100, hhmm % 100);
}
private static Limits limits(Map<String, Double> s) {
return new Limits(whole(s, "max_charge_current_a"), whole(s, "max_discharge_current_a"),
whole(s, "shutdown_soc_pct"), whole(s, "low_soc_pct"), whole(s, "restart_soc_pct"),
on(s, "grid_charge_enabled"), whole(s, "grid_charge_current_a"),
whole(s, "grid_charge_start_soc_pct"));
}
}
@@ -0,0 +1,19 @@
package home.backend.application;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import java.io.IOException;
/** Battery settings through the gateway, which checks every change (ADR-013). */
public interface GatewayBattery {
/** @throws IOException when the gateway has no battery commands, or cannot be reached */
Reserve reserve() throws IOException;
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException;
BatteryResult changeReserve(int pct, String requestedBy) throws IOException;
}
@@ -26,8 +26,13 @@ final class SnapshotFields {
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
return stale(device, reading, now, MAX_AGE);
}
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now,
Duration maxAge) {
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 0;
|| Duration.between(reading.get().time(), now).compareTo(maxAge) > 0;
}
/** One device's fields; empty when it is stale, so nothing old is shown as current. */
@@ -35,8 +40,12 @@ final class SnapshotFields {
}
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now) {
return live(snapshot, device, measurement, now, MAX_AGE);
}
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now, Duration maxAge) {
Optional<Measurement> reading = find(snapshot, device, measurement);
boolean stale = stale(device(snapshot, device), reading, now);
boolean stale = stale(device(snapshot, device), reading, now, maxAge);
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
return new Live(source, stale ? Map.of() : reading.get().fields());
}
@@ -0,0 +1,19 @@
package home.backend.domain;
import java.time.LocalTime;
import java.util.List;
/**
* A change of the Time of Use switch and of single program slots (1 to 6); a null field stays as it is.
* The gateway checks every rule (ADR-013).
*/
public record BatteryChange(Boolean enabled, List<Slot> slots) {
public BatteryChange {
slots = slots == null ? List.of() : List.copyOf(slots);
}
/** {@code from} is the slot's start, to the minute. */
public record Slot(int slot, LocalTime from, Integer socPct, Boolean gridCharge) {
}
}
@@ -0,0 +1,13 @@
package home.backend.domain;
import java.util.Map;
/**
* The gateway's verdict on a battery change, passed on unchanged. REJECTED: refused by the rules, nothing
* written. STALE: the inverter's settings are unknown or changed meanwhile, nothing written. TOO_SOON: S4.
* FAILED: a write or its read-back went wrong; {@code read} has the registers as read back.
*/
public record BatteryResult(Status status, String message, Map<Integer, Integer> read) {
public enum Status { APPLIED, REJECTED, STALE, TOO_SOON, FAILED }
}
@@ -0,0 +1,39 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* The Deye's battery (F7), read-only. Power in W, + discharge / − charge. The live values
* ({@code time}, {@code stale}) come from the Deye's readings and are null when stale; the settings
* ({@code settingsTime}, {@code settingsStale}: mode, schedule, limits, capacity) come from their own,
* slower reading and are null (empty schedule) when stale. The capacity assumes the nominal 51.2 V.
* {@code reserve} is the app's backup reserve (ADR-013) and {@code editable} says the gateway accepts
* changes; they are null and false when it does not.
*/
public record BatteryView(Instant time, boolean stale, Double socPct, Double powerW, Double voltageV,
Double currentA, Double temperatureC, Double capacityKwh, Energy today, Energy total, Mode mode,
List<Slot> schedule, Limits limits, Instant settingsTime, boolean settingsStale, Reserve reserve,
boolean editable) {
/** The share of the battery kept for a power cut, and the lowest the user may set. */
public record Reserve(int pct, int floorPct) {
}
/** Battery energy in kWh. */
public record Energy(Double chargeKwh, Double dischargeKwh) {
}
/** Time of use on means the battery follows the schedule; off means backup only. */
public record Mode(boolean touEnabled, String description) {
}
/** A program slot, {@code from} and {@code to} as HH:MM; it runs until the next slot starts. */
public record Slot(String from, String to, int powerW, int socPct, boolean gridCharge) {
}
public record Limits(Integer maxChargeA, Integer maxDischargeA, Integer shutdownSocPct,
Integer lowSocPct, Integer restartSocPct, boolean gridChargeEnabled, Integer gridChargeCurrentA,
Integer gridChargeStartSocPct) {
}
}
@@ -2,7 +2,11 @@ package home.backend.adapters.gateway;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.Command;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.RestoreResult.Outcome;
@@ -19,6 +23,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
@@ -60,16 +65,20 @@ class GatewayHttpCommandsTest {
}
private void answerCommands() {
server.createContext("/commands", exchange -> {
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)));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
for (String path : List.of("/commands", "/battery")) {
server.createContext(path, this::answer);
}
}
private void answer(HttpExchange exchange) throws IOException {
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)));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
private GatewayHttpCommands commands(String token) {
@@ -216,6 +225,79 @@ class GatewayHttpCommandsTest {
assertEquals(seenBefore, seen.size());
}
@Test
void readsTheBatteryReserveWithTheToken() throws Exception {
serve(200, contract("battery-reserve.json"));
var reserve = commands("s3cret-token").reserve();
assertEquals(new BatteryView.Reserve(50, 30), reserve);
assertEquals("GET", seen.getFirst().method());
assertEquals("/battery/reserve", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
}
@Test
void sendsAScheduleChangeAsTheGatewayExpectsIt() throws Exception {
serve(200, contract("response-battery-applied.json"));
var change = new BatteryChange(true, List.of(new BatteryChange.Slot(2, LocalTime.of(9, 30), 90, true),
new BatteryChange.Slot(3, null, 85, null)));
var result = commands("s3cret-token").changeSchedule(change, "anna");
assertEquals(BatteryResult.Status.APPLIED, result.status());
assertEquals(java.util.Map.of(167, 90, 172, 1), result.read());
assertEquals("POST", seen.getFirst().method());
assertEquals("/battery/schedule", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals("anna", seen.getFirst().requestedBy());
assertEquals(contractJson("request-battery-schedule.json"), contractJson(seen.getFirst().body()));
}
@Test
void sendsAReserveChange() throws Exception {
serve(200, contract("response-battery-applied.json"));
commands("s3cret-token").changeReserve(60, "anna");
assertEquals("/battery/reserve", seen.getFirst().path());
assertEquals(contractJson("{\"pct\":60}"), contractJson(seen.getFirst().body()));
}
@Test
void everyBatteryStatusOfTheGatewayIsAVerdict() throws Exception {
var expected = java.util.Map.of(422, BatteryResult.Status.REJECTED, 409, BatteryResult.Status.STALE,
429, BatteryResult.Status.TOO_SOON, 502, BatteryResult.Status.FAILED);
for (var entry : expected.entrySet()) {
serve(entry.getKey(), "{\"status\":\"" + entry.getValue() + "\",\"message\":\"x\",\"read\":{}}");
assertEquals(entry.getValue(), commands("s3cret-token").changeReserve(60, "anna").status());
}
}
@Test
void aFailedBatteryWriteCarriesWhatWasRead() throws Exception {
serve(502, contract("response-battery-failed.json"));
var result = commands("s3cret-token").changeSchedule(new BatteryChange(null, null), "anna");
assertEquals(BatteryResult.Status.FAILED, result.status());
assertEquals(java.util.Map.of(167, 80), result.read());
}
@Test
void batteryCallsFailWithoutAVerdictOrToken() {
for (int status : List.of(400, 401, 404, 503)) {
serve(status, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").reserve(), "GET " + status);
assertThrows(IOException.class, () -> commands("s3cret-token").changeReserve(60, "anna"), "POST " + status);
}
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).reserve());
assertThrows(IOException.class, () -> commands(null).changeReserve(60, "anna"));
assertEquals(seenBefore, seen.size());
}
@Test
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
@@ -2,12 +2,17 @@ package home.backend.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.adapters.config.PresetFile;
import home.backend.application.GatewayClient;
import home.backend.application.GatewayBattery;
import home.backend.application.GatewayCommands;
import home.backend.application.SetpointService;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.CommandResult.Status;
@@ -64,6 +69,9 @@ class ApiServerTest {
private Outcome restoreOutcome;
private final List<String> restored = new ArrayList<>();
private boolean commandsDown;
private boolean batteryDown;
private BatteryResult.Status batteryVerdict;
private final List<String> batterySent = new ArrayList<>();
private int answersBeforeDrop;
private ApiServer server;
@@ -131,11 +139,46 @@ class ApiServerTest {
return 0;
}
};
batteryDown = false;
batteryVerdict = BatteryResult.Status.APPLIED;
GatewayBattery batteryCommands = new GatewayBattery() {
@Override
public Reserve reserve() throws IOException {
if (batteryDown) {
throw new IOException("gateway /battery/reserve answered 404");
}
return new Reserve(50, 30);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
return answer("schedule " + change + " by " + requestedBy);
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
return answer("reserve " + pct + " by " + requestedBy);
}
private BatteryResult answer(String what) throws IOException {
if (batteryDown) {
throw new IOException("tunnel down");
}
batterySent.add(what);
return switch (batteryVerdict) {
case APPLIED -> new BatteryResult(batteryVerdict, "applied", Map.of(167, 90));
case FAILED -> new BatteryResult(batteryVerdict,
"registers not as written: [167]; not retried", Map.of(167, 80));
default -> new BatteryResult(batteryVerdict, "nope", Map.of());
};
}
};
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock));
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
new BatteryService(gateway, batteryCommands, clock));
server.start();
}
@@ -155,10 +198,13 @@ class ApiServerTest {
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
"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);
"load_today_kwh", 24.2));
deye.putAll(Map.of("battery_voltage_v", 56.49, "battery_current_a", -0.05, "battery_temperature_c", 19.2,
"battery_charge_today_kwh", 0.5, "battery_discharge_today_kwh", 0.1,
"battery_charge_total_kwh", 453.7, "battery_discharge_total_kwh", 162.1));
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))),
@@ -171,11 +217,29 @@ class ApiServerTest {
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))),
new DeviceSnapshot("deye-settings", true, energyTime, null,
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
new DeviceSnapshot("bme280", true, energyTime, null,
List.of(new Measurement("measurement", "MEASUREMENT",
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8), energyTime)))));
}
/** The settings read from the inverter on 2026-10-24: time of use off, six 4-hour slots. */
private static Map<String, Double> deyeSettings() {
Map<String, Double> settings = new HashMap<>(Map.of("battery_capacity_ah", 280.0, "tou_enabled", 0.0,
"max_charge_current_a", 80.0, "max_discharge_current_a", 120.0, "shutdown_soc_pct", 20.0,
"low_soc_pct", 35.0, "restart_soc_pct", 50.0, "grid_charge_enabled", 1.0,
"grid_charge_current_a", 40.0, "grid_charge_start_soc_pct", 30.0));
double[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int slot = 1; slot <= 6; slot++) {
settings.put("prog" + slot + "_start_hhmm", starts[slot - 1]);
settings.put("prog" + slot + "_power_w", 10000.0);
settings.put("prog" + slot + "_soc_pct", 80.0);
settings.put("prog" + slot + "_grid_charge", 0.0);
}
return settings;
}
private HttpResponse<String> get(String path) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
.build();
@@ -241,7 +305,103 @@ class ApiServerTest {
}
@Test
void piSensorMatchesExample() throws Exception {
void batteryMatchesExample() throws Exception {
assertMatchesExample("/api/battery", "battery");
}
@Test
void batteryWithStaleSettingsMatchesExample() throws Exception {
Instant old = Instant.parse("2026-10-23T21:00:00Z");
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
devices.set(devices.size() - 2, new DeviceSnapshot("deye-settings", true, old, null,
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), old))));
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/battery", "battery-settings-stale");
}
@Test
void batteryWithoutGatewayCommandsIsNotEditable() throws Exception {
batteryDown = true;
var body = JSON.readTree(get("/api/battery").body());
assertTrue(body.get("reserve").isNull());
assertEquals(false, body.get("editable").asBoolean());
assertEquals(99.0, body.get("socPct").asDouble());
}
@Test
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
var response = post("/api/battery/schedule", example("battery-schedule-request").toString(),
"Remote-User", "anna");
assertEquals(200, response.statusCode());
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
new BatteryChange.Slot(2, java.time.LocalTime.of(9, 30), 90, true),
new BatteryChange.Slot(3, null, 85, null))) + " by anna"), batterySent);
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
JSON.readTree(response.body()));
}
@Test
void reserveChangeGoesToTheGateway() throws Exception {
var response = post("/api/battery/reserve", example("battery-reserve-request").toString());
assertEquals(200, response.statusCode());
assertEquals(List.of("reserve 60 by phone"), batterySent);
}
@Test
void batteryStatusFollowsTheGatewaysVerdict() throws Exception {
var expected = Map.of(BatteryResult.Status.REJECTED, 422, BatteryResult.Status.STALE, 409,
BatteryResult.Status.TOO_SOON, 429, BatteryResult.Status.FAILED, 502);
for (var entry : expected.entrySet()) {
batteryVerdict = entry.getKey();
assertEquals(entry.getValue(), post("/api/battery/reserve", "{\"pct\":60}").statusCode());
assertEquals(entry.getValue(), post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
}
batteryVerdict = BatteryResult.Status.FAILED;
assertEquals(example("battery-failed"),
JSON.readTree(post("/api/battery/schedule", "{\"enabled\":false}").body()));
}
@Test
void batteryChangeWithTheGatewayDownIs503() throws Exception {
batteryDown = true;
assertEquals(503, post("/api/battery/reserve", "{\"pct\":60}").statusCode());
assertEquals(503, post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
}
@Test
void badBatteryRequestsAre400AndSendNothing() throws Exception {
for (String body : List.of("", "not json", "[]", "{}", "{\"pct\":\"60\"}", "{\"pct\":60.5}", "{\"pct\":null}",
"{\"pct\":60,\"x\":1}")) {
assertEquals(400, post("/api/battery/reserve", body).statusCode(), body);
}
for (String body : List.of("", "not json", "[]", "{\"enabled\":\"yes\"}", "{\"slots\":[{\"slot\":1,\"from\":\"9h\"}]}",
"{\"slots\":[{\"slot\":1,\"from\":\"25:00\"}]}", "{\"slots\":[{\"slot\":1,\"socPct\":\"9\"}]}",
"{\"slots\":[{\"slot\":1,\"soc\":9}]}", "{\"slots\":[{\"slot\":1,\"gridCharge\":1}]}")) {
assertEquals(400, post("/api/battery/schedule", body).statusCode(), body);
}
assertTrue(batterySent.isEmpty());
}
@Test
void batteryWritesAreGuardedLikeEveryWrite() throws Exception {
for (String path : List.of("/api/battery/reserve", "/api/battery/schedule")) {
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode(), path);
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode(), path);
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode(), path);
assertEquals(405, get(path).statusCode(), path);
}
assertEquals(404, post("/api/battery/other", "{}").statusCode());
assertTrue(batterySent.isEmpty());
}
@Test
void piSensorMatchesExample()throws Exception {
assertMatchesExample("/api/pi-sensor", "pi-sensor");
}
@@ -1,5 +1,8 @@
package home.backend.application;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.EnergyDetails;
import home.backend.domain.EnergyDetails.Device;
import home.backend.domain.EnergyDetails.Group;
@@ -23,6 +26,7 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -171,6 +175,162 @@ class ServicesTest {
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
}
private static Map<String, Double> settings(double touEnabled) {
Map<String, Double> fields = new HashMap<>(Map.of("battery_capacity_ah", 280.0,
"tou_enabled", touEnabled, "grid_charge_enabled", 0.0));
double[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int n = 1; n <= 6; n++) {
fields.putAll(Map.of("prog" + n + "_start_hhmm", starts[n - 1], "prog" + n + "_power_w", 8000.0,
"prog" + n + "_soc_pct", 60.0, "prog" + n + "_grid_charge", n == 2 ? 1.0 : 0.0));
}
return fields;
}
private static BatteryView battery(DeviceSnapshot... devices) throws IOException {
return new BatteryService(gateway(devices), new FakeBattery(), CLOCK).current();
}
private static final class FakeBattery implements GatewayBattery {
static final List<String> SENT = new java.util.ArrayList<>();
final boolean enabled;
FakeBattery() {
this(true);
}
FakeBattery(boolean enabled) {
this.enabled = enabled;
}
@Override
public BatteryView.Reserve reserve() throws IOException {
if (!enabled) {
throw new IOException("gateway /battery/reserve answered 404");
}
return new BatteryView.Reserve(50, 30);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) {
SENT.add("schedule by " + requestedBy);
return new BatteryResult(BatteryResult.Status.APPLIED, "applied", Map.of());
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) {
SENT.add("reserve " + pct + " by " + requestedBy);
return new BatteryResult(BatteryResult.Status.APPLIED, "applied", Map.of());
}
}
@Test
void batteryShowsTheReserveAndIsEditableWhenTheGatewayAcceptsChanges() throws IOException {
BatteryView view = battery();
assertEquals(new BatteryView.Reserve(50, 30), view.reserve());
assertTrue(view.editable());
}
@Test
void batteryIsNotEditableWithoutGatewayCommandsButStillShowsTheLiveState() throws IOException {
BatteryView view = new BatteryService(gateway(device("deye", "home", "deye",
Map.of("battery_soc_pct", 80.0))), new FakeBattery(false), CLOCK).current();
assertNull(view.reserve());
assertFalse(view.editable());
assertEquals(80.0, view.socPct());
}
@Test
void batteryChangesAreHandedToTheGatewayWithTheUser() throws IOException {
var service = new BatteryService(gateway(), new FakeBattery(), CLOCK);
assertEquals(BatteryResult.Status.APPLIED, service.changeReserve(60, "anna").status());
assertEquals(BatteryResult.Status.APPLIED,
service.changeSchedule(new BatteryChange(true, null), "ola").status());
assertEquals(List.of("reserve 60 by anna", "schedule by ola"), FakeBattery.SENT);
}
private static DeviceSnapshot deyeSettings(Map<String, Double> fields, Instant time) {
return new DeviceSnapshot("deye-settings", true, time, null,
List.of(new Measurement("electricity", "deye_settings", fields, time)));
}
@Test
void batteryReadsLiveValuesAndComputesCapacityAtNominalVoltage() throws IOException {
BatteryView view = battery(device("deye", "home", "deye", Map.of("battery_soc_pct", 80.0,
"battery_power_w", 1500.0, "battery_charge_today_kwh", 2.5)),
deyeSettings(settings(0), READ));
assertFalse(view.stale());
assertEquals(80.0, view.socPct());
assertEquals(1500.0, view.powerW());
assertEquals(2.5, view.today().chargeKwh());
assertEquals(14.336, view.capacityKwh(), 1e-9);
}
@Test
void batteryScheduleRunsToTheNextStartAndWrapsAroundMidnight() throws IOException {
List<BatteryView.Slot> schedule = battery(deyeSettings(settings(1), READ)).schedule();
assertEquals(6, schedule.size());
assertEquals(new BatteryView.Slot("01:00", "05:00", 8000, 60, false), schedule.get(0));
assertEquals(new BatteryView.Slot("05:00", "09:00", 8000, 60, true), schedule.get(1));
assertEquals(new BatteryView.Slot("21:00", "01:00", 8000, 60, false), schedule.get(5));
}
@Test
void batteryModeFollowsTimeOfUse() throws IOException {
BatteryView backup = battery(deyeSettings(settings(0), READ));
BatteryView scheduled = battery(deyeSettings(settings(1), READ));
assertFalse(backup.mode().touEnabled());
assertEquals("Rezerwa na awarię: bateria nie rozładowuje się na co dzień", backup.mode().description());
assertTrue(scheduled.mode().touEnabled());
assertEquals("Harmonogram: bateria pracuje według przedziałów", scheduled.mode().description());
}
@Test
void batterySettingsAreStaleAfterThirtyMinutesButLiveValuesStayCurrent() throws IOException {
var live = device("deye", "home", "deye", Map.of("battery_soc_pct", 80.0));
BatteryView fresh = battery(live, deyeSettings(settings(0), NOW.minusSeconds(1800)));
BatteryView old = battery(live, deyeSettings(settings(0), NOW.minusSeconds(1801)));
assertFalse(fresh.settingsStale());
assertEquals(6, fresh.schedule().size());
assertTrue(old.settingsStale());
assertEquals(NOW.minusSeconds(1801), old.settingsTime());
assertTrue(old.schedule().isEmpty());
assertNull(old.mode());
assertNull(old.limits());
assertNull(old.capacityKwh());
assertEquals(80.0, old.socPct());
}
@Test
void staleDeyeShowsNoLiveBatteryValues() throws IOException {
var old = new Measurement("home", "deye", Map.of("battery_soc_pct", 80.0), NOW.minusSeconds(601));
BatteryView view = battery(new DeviceSnapshot("deye", true, old.time(), null, List.of(old)),
deyeSettings(settings(0), READ));
assertTrue(view.stale());
assertNull(view.socPct());
assertNull(view.powerW());
assertFalse(view.settingsStale());
}
@Test
void batteryWithoutAnyReadingIsStaleTwice() throws IOException {
BatteryView view = battery();
assertTrue(view.stale());
assertTrue(view.settingsStale());
assertNull(view.time());
assertNull(view.settingsTime());
}
private static PiSensorView piSensor(DeviceSnapshot... devices) throws IOException {
return new PiSensorService(gateway(devices), CLOCK).current();
}
@@ -0,0 +1 @@
{ "status": "FAILED", "message": "registers not as written: [167]; not retried", "read": { "167": 80 } }
@@ -0,0 +1 @@
{ "pct": 60 }
@@ -0,0 +1,7 @@
{
"enabled": true,
"slots": [
{ "slot": 2, "from": "09:30", "socPct": 90, "gridCharge": true },
{ "slot": 3, "socPct": 85 }
]
}
@@ -0,0 +1,19 @@
{
"time": "2026-10-23T22:26:00Z",
"stale": false,
"socPct": 99.0,
"powerW": -24.0,
"voltageV": 56.49,
"currentA": -0.05,
"temperatureC": 19.2,
"capacityKwh": null,
"today": { "chargeKwh": 0.5, "dischargeKwh": 0.1 },
"total": { "chargeKwh": 453.7, "dischargeKwh": 162.1 },
"mode": null,
"schedule": [],
"limits": null,
"settingsTime": "2026-10-23T21:00:00Z",
"settingsStale": true,
"reserve": { "pct": 50, "floorPct": 30 },
"editable": true
}
@@ -0,0 +1,38 @@
{
"time": "2026-10-23T22:26:00Z",
"stale": false,
"socPct": 99.0,
"powerW": -24.0,
"voltageV": 56.49,
"currentA": -0.05,
"temperatureC": 19.2,
"capacityKwh": 14.336,
"today": { "chargeKwh": 0.5, "dischargeKwh": 0.1 },
"total": { "chargeKwh": 453.7, "dischargeKwh": 162.1 },
"mode": {
"touEnabled": false,
"description": "Rezerwa na awarię: bateria nie rozładowuje się na co dzień"
},
"schedule": [
{ "from": "01:00", "to": "05:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "05:00", "to": "09:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "09:00", "to": "13:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "13:00", "to": "17:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "17:00", "to": "21:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "21:00", "to": "01:00", "powerW": 10000, "socPct": 80, "gridCharge": false }
],
"limits": {
"maxChargeA": 80,
"maxDischargeA": 120,
"shutdownSocPct": 20,
"lowSocPct": 35,
"restartSocPct": 50,
"gridChargeEnabled": true,
"gridChargeCurrentA": 40,
"gridChargeStartSocPct": 30
},
"settingsTime": "2026-10-23T22:26:00Z",
"settingsStale": false,
"reserve": { "pct": 50, "floorPct": 30 },
"editable": true
}
@@ -5,7 +5,10 @@
"groups": [
{ "title": "Bateria", "items": [
{ "label": "Naładowanie", "value": 99.0, "unit": "%", "digits": 0 },
{ "label": "Moc", "value": -24.0, "unit": "W", "digits": 0 }
{ "label": "Moc", "value": -24.0, "unit": "W", "digits": 0 },
{ "label": "Napięcie", "value": 56.5, "unit": "V", "digits": 1 },
{ "label": "Prąd", "value": 0.0, "unit": "A", "digits": 1 },
{ "label": "Temperatura", "value": 19.2, "unit": "°C", "digits": 1 }
] },
{ "title": "Sieć", "items": [
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
@@ -21,7 +24,13 @@
{ "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 }
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 },
{ "label": "Bateria naładowana", "value": 0.5, "unit": "kWh", "digits": 1 },
{ "label": "Bateria rozładowana", "value": 0.1, "unit": "kWh", "digits": 1 }
] },
{ "title": "Od początku", "items": [
{ "label": "Bateria naładowana", "value": 454.0, "unit": "kWh", "digits": 0 },
{ "label": "Bateria rozładowana", "value": 162.0, "unit": "kWh", "digits": 0 }
] }
]
},
@@ -0,0 +1,36 @@
# ADR-013: Battery settings are changed from the app
Status: accepted · 2026-10-24
## Context
The Deye battery is the house's UPS: Time of Use is off and the battery stays full. My father wants to
change its program from our app instead of the Solarman cloud. A wrong table can empty the battery
before a power cut, so the write path is narrow and refuses when unsure.
## Decision
- **Allowed registers only** (FC16, through the one logger link): 146 bit 0 (Time of Use switch; the days
in bits 1–7 stay as read), 148–153 slot starts, 166–171 slot SOC, 172–177 grid charge (0 or 1, never the
generator values 2 and 3). Nothing else is written.
- **Rules**: SOC from the reserve to 100; starts HHMM, strictly increasing from slot 1 to 6; an enabled
schedule needs every slot SOC at or above the reserve. The whole table is checked before any write.
- **Reserve**: an app setting in `reserve.json` on the Pi, default 50 %, never below the configured floor
(30) nor below the inverter's shutdown SOC + 10. It is only raised above a slot SOC while the schedule is
off; the user lifts the slots first. Lowering is always fine.
- **Refuse on unknown state**: no reading of the settings in the last 30 minutes, or the registers read just
before writing differ from that reading (the Solarman app changed them): 409, nothing written.
- **Verify, never retry**: neighbouring registers go in one FC16, the switch last; all are read back; any
mismatch is FAILED with what was read. The user decides what to do next.
- **No restore, no lease**: these are persistent settings as in the Solarman app; the reserve is what
protects the UPS role (ADR-011 stays for the heat pump).
- **Audit and limits**: S3 into `electricity.deye_settings_audit`, S4 per register, token and throttle as
`/commands`. Off unless `deyeCommands.enabled`, which needs the Deye.
## Consequences
- The gateway writes to the inverter for the first time; registers beyond the list stay read-only.
- A change made in the Solarman app while the app shows the old table is refused, not overwritten.
- The reserve is enforced only through the slot SOC rule; the inverter does not know it.
- The shutdown SOC comes from the last settings reading; a change of it applies from the next poll.
- The Solarman cloud stays outside our system; the logger link has one writer at a time.
+1
View File
@@ -40,6 +40,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) and 15 min leases the backend renews (ADR-011), away held until a chosen date (ADR-012); 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 |
| M4b Battery settings | F8 (ADR-013): Time of Use table and backup reserve from the Bateria view, FC16 with read-back, refused on stale or changed state; on branch/master and off until `deyeCommands.enabled` (gateway/README.md: Enabling battery commands) | Father changes the battery program from the app; first live write watched |
| 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 |
+4 -2
View File
@@ -20,7 +20,7 @@ In:
- Access from outside through my server; Polish UI for my parents.
Out, until decided otherwise:
- Writing to the inverters or the battery, and controlling PV.
- Writing to the inverters beyond the battery program and reserve of F8, and controlling PV.
- Editing the weekly program of the heat pump.
- Cloud services (Solarman, Frisko Online) and the logger's push to a second server.
- Replacing Grafana.
@@ -91,7 +91,9 @@ Functional:
- 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; a details panel per device (Deye, Fronius, house meter).
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals; a details panel per device (Deye, Fronius, house meter); a battery view with SOC, power, the Deye's program schedule and limits.
- F8 Battery settings (ADR-013, off until enabled): change the Deye's Time of Use switch and the six slots (start, SOC, grid charge) from the Bateria view, and the app's backup reserve (default 50 %, floor 30 %) that every enabled slot SOC must hold. Refused when the settings reading is older than 30 min or changed meanwhile; read back, no restore.
Safety, for every write:
+7
View File
@@ -16,3 +16,10 @@ 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.
The Energia screen has three views, kept in the URL hash so Back works: `#energy` (Teraz), `#energy/battery` (Bateria, also
opened by tapping the battery in the flow diagram) and `#energy/inverters` (Falowniki, the device details).
In the Bateria view, `editable` from `api/battery` (the gateway takes battery changes, ADR-013) adds the reserve stepper and "Edytuj" for the
schedule: the user confirms a summary, only changed fields are sent to `api/battery/schedule` and `api/battery/reserve`, and the UI
checks the gateway's rules first (levels from the reserve to 100 %, starts increasing).
+1 -1
View File
@@ -21,7 +21,7 @@ const SCREENS = {
type ScreenId = keyof typeof SCREENS;
const fromHash = (): ScreenId => {
const id = location.hash.slice(1);
const id = location.hash.slice(1).split("/")[0];
return id in SCREENS ? (id as ScreenId) : "overview";
};
+107
View File
@@ -0,0 +1,107 @@
import { render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { Battery as BatteryData } from "./types";
// A fresh module per test: the poller keeps its answers between screens, not between tests.
async function show(patch: Partial<BatteryData> = {}) {
serve({ "api/battery": { ...example<BatteryData>("battery"), ...patch } });
const { Battery } = await import("./Battery");
render(<Battery />);
await screen.findByText("Bateria teraz");
}
const touOn: Partial<BatteryData> = {
mode: { touEnabled: true, description: "Harmonogram: bateria pracuje według przedziałów" },
};
beforeEach(() => {
location.hash = "";
});
describe("Battery view", () => {
it("shows the charge with its kWh, and no flow as standing still", async () => {
await show({ powerW: 3 });
expect(screen.getByRole("img", { name: "Naładowanie baterii: 99 %" })).toBeInTheDocument();
expect(screen.getByText("14,2 kWh z 14,3 kWh")).toBeInTheDocument();
expect(screen.getByText("stoi")).toBeInTheDocument();
});
it("says charging in words with the size of the flow", async () => {
await show({ powerW: -1500 });
expect(screen.getByText("ładuje")).toBeInTheDocument();
expect(screen.getByText("1,5 kW")).toBeInTheDocument();
});
it("says discharging for a positive power", async () => {
await show({ powerW: 400 });
expect(screen.getByText("rozładowuje")).toBeInTheDocument();
expect(screen.getByText("400 W")).toBeInTheDocument();
});
it("explains the backup mode and dims the schedule as wyłączony", async () => {
await show();
expect(screen.getByText("Rezerwa na awarię: bateria nie rozładowuje się na co dzień")).toBeInTheDocument();
const table = screen.getByRole("table");
expect(screen.getByText("wyłączony")).toBeInTheDocument();
expect(table).toHaveClass("opacity-50");
expect(within(table).getAllByRole("row")).toHaveLength(7);
expect(within(table).getByRole("row", { name: /21:00–01:00/ })).toHaveTextContent("80 %nie");
});
it("shows an active schedule at full strength with the grid charging per slot", async () => {
const slots = example<BatteryData>("battery").schedule.map((s, i) => ({ ...s, gridCharge: i === 0 }));
await show({ ...touOn, schedule: slots });
const table = screen.getByRole("table");
expect(screen.queryByText("wyłączony")).toBeNull();
expect(table).not.toHaveClass("opacity-50");
expect(within(table).getByRole("row", { name: /01:00–05:00/ })).toHaveTextContent("tak");
expect(within(table).getByRole("row", { name: /05:00–09:00/ })).toHaveTextContent("nie");
});
it("lists the limits, today's and the total energy and when the settings were read", async () => {
await show();
expect(screen.getByText("Maks. prąd ładowania").parentElement).toHaveTextContent("80 A");
expect(screen.getByText("Wyłączenie przy").parentElement).toHaveTextContent("20 %");
expect(screen.getByText("Prąd ładowania z sieci").parentElement).toHaveTextContent("40 A");
expect(screen.getByText("Dziś naładowano").parentElement).toHaveTextContent("0,5 kWh");
expect(screen.getByText("Od początku rozładowano").parentElement).toHaveTextContent("162,1 kWh");
expect(screen.getByText(/Ustawienia odczytane: \d\d:\d\d/)).toBeInTheDocument();
});
it("hides the grid charging details when it is off", async () => {
const limits = { ...example<BatteryData>("battery").limits!, gridChargeEnabled: false };
await show({ limits });
const card = screen.getByRole("heading", { name: "Limity" }).closest("section")!;
expect(within(card).getByText("Ładowanie z sieci").parentElement).toHaveTextContent("nie");
expect(screen.queryByText("Prąd ładowania z sieci")).toBeNull();
});
it("without current settings shows a warning instead of a stale schedule", async () => {
await show({ mode: null, schedule: [], limits: null, capacityKwh: null, settingsStale: true,
settingsTime: "2026-10-23T21:00:00Z" });
expect(screen.getByText(/Ustawienia baterii są nieaktualne \(ostatnio \d\d:\d\d\)/)).toBeInTheDocument();
expect(screen.queryByRole("table")).toBeNull();
expect(screen.queryByText("Limity")).toBeNull();
expect(screen.queryByText(/Ustawienia odczytane/)).toBeNull();
expect(screen.getByRole("img", { name: "Naładowanie baterii: 99 %" })).toBeInTheDocument();
});
it("without a live reading shows dashes and a stale warning", async () => {
await show({ stale: true, time: "2026-10-23T21:00:00Z", socPct: null, powerW: null, voltageV: null,
currentA: null, temperatureC: null, today: { chargeKwh: null, dischargeKwh: null },
total: { chargeKwh: null, dischargeKwh: null } });
expect(screen.getByText(/Dane są nieaktualne/)).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Naładowanie baterii: —" })).toBeInTheDocument();
expect(screen.getByText("Dziś naładowano").parentElement).toHaveTextContent("—");
});
});
+118
View File
@@ -0,0 +1,118 @@
import { useState } from "react";
import { flowing } from "./flow";
import { amps, DASH, hour, kwh, number, percent, power } from "./format";
import { usePoll } from "./poll";
import type { Battery as BatteryData } from "./types";
import { Reserve, Schedule, type Notice } from "./BatterySettings";
import { Banner, Card, Freshness, Loading, Row } from "./ui";
/** What the battery does, in one word: + discharge / − charge. */
export function batteryAction(watts: number | null) {
if (watts == null) return DASH;
if (!flowing(watts)) return "stoi";
return watts > 0 ? "rozładowuje" : "ładuje";
}
const RING = { size: 168, stroke: 16 };
const RADIUS = (RING.size - RING.stroke) / 2;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function Gauge({ soc, stored, capacity }: { soc: number | null; stored: number | null; capacity: number | null }) {
const filled = soc == null ? 0 : (Math.min(100, Math.max(0, soc)) / 100) * CIRCUMFERENCE;
const centre = RING.size / 2;
return (
<div className="relative size-44 shrink-0">
<svg viewBox={`0 0 ${RING.size} ${RING.size}`} role="img" aria-label={`Naładowanie baterii: ${percent(soc)}`} className="size-full -rotate-90">
<circle cx={centre} cy={centre} r={RADIUS} fill="none" stroke="var(--color-edge)" strokeWidth={RING.stroke} />
<circle cx={centre} cy={centre} r={RADIUS} fill="none" stroke="var(--color-brand)" strokeWidth={RING.stroke}
strokeLinecap="round" strokeDasharray={`${filled} ${CIRCUMFERENCE}`} />
</svg>
<div className="absolute inset-0 grid place-items-center text-center">
<div>
<p className="text-4xl font-bold tabular-nums">{percent(soc)}</p>
{stored != null && capacity != null && <p className="text-sm text-soft">{kwh(stored)} z {kwh(capacity)}</p>}
</div>
</div>
</div>
);
}
function Now({ b }: { b: BatteryData }) {
const stored = b.socPct != null && b.capacityKwh != null ? (b.socPct / 100) * b.capacityKwh : null;
return (
<Card title="Bateria teraz">
<div className="flex flex-wrap items-center gap-5">
<Gauge soc={b.socPct} stored={stored} capacity={b.capacityKwh} />
<div className="min-w-44 flex-1">
<p className={`text-2xl font-bold ${flowing(b.powerW) ? "text-run" : ""}`}>
{batteryAction(b.powerW)}
{flowing(b.powerW) && <span className="tabular-nums"> {power(Math.abs(b.powerW!))}</span>}
</p>
<div className="mt-2 text-sm">
<Row label="Napięcie" value={b.voltageV == null ? DASH : `${number(b.voltageV, 1)} V`} />
<Row label="Prąd" value={b.currentA == null ? DASH : `${number(Math.abs(b.currentA), 1)} A`} />
<Row label="Temperatura" value={b.temperatureC == null ? DASH : `${number(b.temperatureC, 1)} °C`} />
</div>
</div>
</div>
</Card>
);
}
function Limits({ b }: { b: BatteryData }) {
const l = b.limits!;
return (
<Card title="Limity">
<Row label="Maks. prąd ładowania" value={amps(l.maxChargeA)} />
<Row label="Maks. prąd rozładowania" value={amps(l.maxDischargeA)} />
<Row label="Wyłączenie przy" value={percent(l.shutdownSocPct)} />
<Row label="Niski poziom" value={percent(l.lowSocPct)} />
<Row label="Ponowne włączenie przy" value={percent(l.restartSocPct)} />
<Row label="Ładowanie z sieci" value={l.gridChargeEnabled ? "tak" : "nie"} />
{l.gridChargeEnabled && <Row label="Prąd ładowania z sieci" value={amps(l.gridChargeCurrentA)} />}
{l.gridChargeEnabled && <Row label="Ładowanie z sieci od" value={percent(l.gridChargeStartSocPct)} />}
</Card>
);
}
function Totals({ b }: { b: BatteryData }) {
return (
<Card title="Energia">
<Row label="Dziś naładowano" value={kwh(b.today.chargeKwh)} />
<Row label="Dziś rozładowano" value={kwh(b.today.dischargeKwh)} />
<Row label="Od początku naładowano" value={kwh(b.total.chargeKwh)} />
<Row label="Od początku rozładowano" value={kwh(b.total.dischargeKwh)} />
</Card>
);
}
export function Battery() {
const { data: b, offline } = usePoll<BatteryData>("api/battery");
const [notice, setNotice] = useState<Notice>(null);
if (!b) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
return (
<>
<Freshness offline={offline} time={b.time} stale={b.stale} />
{notice && <Banner tone={notice.ok ? "ok" : "bad"}>{notice.text}</Banner>}
<div className="grid gap-4 lg:grid-cols-2">
<div className="lg:col-span-2"><Now b={b} /></div>
{b.settingsStale || !b.mode || !b.limits ? (
<div className="lg:col-span-2">
<Banner tone="warn">Ustawienia baterii są nieaktualne{b.settingsTime ? ` (ostatnio ${hour(new Date(b.settingsTime))})` : ", brak odczytu"}.</Banner>
</div>
) : (
<>
<Card title="Tryb pracy" className="lg:col-span-2"><p className="text-lg font-semibold">{b.mode.description}</p></Card>
<Schedule b={b} onDone={setNotice} />
<Limits b={b} />
</>
)}
{b.reserve && <Reserve b={b} onDone={setNotice} />}
<div className="lg:col-span-2"><Totals b={b} /></div>
</div>
{b.settingsTime && !b.settingsStale && (
<p className="mt-4 text-center text-sm text-soft">Ustawienia odczytane: {hour(new Date(b.settingsTime))}</p>
)}
</>
);
}
+267
View File
@@ -0,0 +1,267 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { answer, example, serve } from "./fixtures";
import type { Battery as BatteryData } from "./types";
const touOn = { touEnabled: true, description: "Harmonogram: bateria pracuje według przedziałów" };
// A fresh module per test: the poller keeps its answers between screens, not between tests.
async function show(patch: Partial<BatteryData> = {}, onPost = () => answer({ status: "APPLIED", message: "applied", read: {} })) {
const fetch = serve({ "api/battery": { ...example<BatteryData>("battery"), ...patch } }, onPost);
const { Battery } = await import("./Battery");
render(<Battery />);
await screen.findByText("Bateria teraz");
return fetch;
}
const posts = (fetch: ReturnType<typeof serve>) =>
fetch.mock.calls.filter(([, init]) => init?.method === "POST").map(([url, init]) => [url, JSON.parse(String(init!.body))]);
const click = (name: string | RegExp, role = "button") => userEvent.click(screen.getByRole(role, { name }));
const more = (label: string) => userEvent.click(screen.getByLabelText(`Więcej: ${label}`));
const less = (label: string) => userEvent.click(screen.getByLabelText(`Mniej: ${label}`));
const edit = async () => click("Edytuj");
// The staleness banner of the old fixture is an alert too.
const alerts = () => screen.queryAllByRole("alert").map((el) => el.textContent ?? "").filter((text) => !text.startsWith("Dane są nieaktualne"));
beforeEach(() => {
location.hash = "";
window.scrollTo = () => {};
});
describe("Battery reserve", () => {
it("shows the reserve with its explanation", async () => {
await show();
expect(screen.getByText("Rezerwa na awarię").parentElement).toHaveTextContent("50 %");
expect(screen.getByText(/Bateria nie rozładuje się poniżej tej wartości na co dzień; przy braku prądu zostaje dla domu\./)).toBeInTheDocument();
});
it("steps within the bounds of the API and writes only after the summary is confirmed", async () => {
const fetch = await show();
for (let i = 0; i < 6; i++) await less("rezerwa na awarię");
expect(screen.getByLabelText("Mniej: rezerwa na awarię")).toBeDisabled();
expect(screen.getByText("od 30 % do 100 %")).toBeInTheDocument();
for (let i = 0; i < 2; i++) await more("rezerwa na awarię");
await click("Zapisz");
expect(posts(fetch)).toEqual([]);
expect(screen.getByRole("group", { name: "Potwierdzenie zmiany" })).toHaveTextContent("Rezerwa na awarię: z 50 % na 40 %");
await click("Tak, zapisz");
expect(posts(fetch)).toEqual([["api/battery/reserve", { pct: 40 }]]);
expect(await screen.findByText(/^Zapisano\./)).toBeInTheDocument();
});
it("cannot go above 100 %", async () => {
await show({ reserve: { pct: 98, floorPct: 30 } });
await more("rezerwa na awarię");
expect(screen.getByLabelText("Więcej: rezerwa na awarię")).toBeDisabled();
expect(screen.getByRole("button", { name: "Zapisz" })).toBeEnabled();
});
it("refuses to raise it above a slot level while the schedule is enabled", async () => {
const fetch = await show({ mode: touOn, reserve: { pct: 78, floorPct: 30 } });
await more("rezerwa na awarię");
expect(alerts()).toEqual([expect.stringContaining("Najpierw podnieś poziomy w harmonogramie albo go wyłącz.")]);
expect(screen.getByRole("button", { name: "Zapisz" })).toBeDisabled();
expect(posts(fetch)).toEqual([]);
});
it("allows raising it while the schedule is off", async () => {
await show({ reserve: { pct: 78, floorPct: 30 } });
await more("rezerwa na awarię");
expect(alerts()).toEqual([]);
expect(screen.getByRole("button", { name: "Zapisz" })).toBeEnabled();
});
it("anuluj drops the draft", async () => {
const fetch = await show();
await less("rezerwa na awarię");
await click("Anuluj");
expect(screen.getByLabelText("Mniej: rezerwa na awarię").parentElement).toHaveTextContent("50 %");
expect(posts(fetch)).toEqual([]);
});
it("is only shown, not editable, when the gateway takes no changes", async () => {
await show({ editable: false });
expect(screen.getByText("Rezerwa na awarię").parentElement).toHaveTextContent("50 %");
expect(screen.queryByLabelText("Mniej: rezerwa na awarię")).toBeNull();
expect(screen.queryByRole("button", { name: "Edytuj" })).toBeNull();
});
it("has no reserve card without a reserve", async () => {
await show({ reserve: null, editable: false });
expect(screen.queryByText("Rezerwa na awarię")).toBeNull();
expect(screen.getByRole("table")).toBeInTheDocument();
});
});
describe("Battery schedule editing", () => {
it("edits the switch, a start, a level and the grid charging and sends only what changed", async () => {
const fetch = await show();
await edit();
await click("Harmonogram włączony", "switch");
fireEvent.change(screen.getByLabelText("Początek przedziału 2"), { target: { value: "07:00" } });
await more("poziom, przedział 3");
await click("Ładowanie z sieci, przedział 1", "switch");
await click("Zapisz");
const summary = screen.getByRole("group", { name: "Potwierdzenie zmiany" });
expect(summary).toHaveTextContent("Harmonogram: włączony");
expect(summary).toHaveTextContent("Przedział 1: ładowanie z sieci nie → tak");
expect(summary).toHaveTextContent("Przedział 2: początek 05:00 → 07:00");
expect(summary).toHaveTextContent("Przedział 3: poziom 80 % → 85 %");
expect(posts(fetch)).toEqual([]);
await click("Tak, zapisz");
expect(posts(fetch)).toEqual([["api/battery/schedule", { enabled: true, slots: [
{ slot: 1, gridCharge: true }, { slot: 2, from: "07:00" }, { slot: 3, socPct: 85 }] }]]);
expect(await screen.findByText(/^Zapisano\./)).toBeInTheDocument();
expect(screen.queryByText("Harmonogram: edycja")).toBeNull();
});
it("starts from the current table and can go back without sending", async () => {
const fetch = await show({ mode: touOn });
await edit();
expect(screen.getByRole("switch", { name: "Harmonogram włączony" })).toBeChecked();
expect(screen.getByLabelText("Początek przedziału 4")).toHaveValue("13:00");
expect(screen.getByRole("button", { name: "Zapisz" })).toBeDisabled();
await click("Anuluj");
expect(screen.getByRole("table")).toBeInTheDocument();
expect(posts(fetch)).toEqual([]);
});
it("makes a level below the reserve impossible", async () => {
await show();
await edit();
for (let i = 0; i < 8; i++) await less("poziom, przedział 1");
expect(screen.getByLabelText("Mniej: poziom, przedział 1").parentElement).toHaveTextContent("50 %");
expect(screen.getByLabelText("Mniej: poziom, przedział 1")).toBeDisabled();
});
it("takes the reserve from the API as the lowest level", async () => {
await show({ reserve: { pct: 80, floorPct: 30 } });
await edit();
expect(screen.getByLabelText("Mniej: poziom, przedział 2")).toBeDisabled();
expect(screen.getByText(/nie może być niższy niż rezerwa na awarię \(80 %\)/)).toBeInTheDocument();
});
it("cannot be enabled while a level is below the reserve", async () => {
const schedule = example<BatteryData>("battery").schedule.map((slot, i) => ({ ...slot, socPct: i === 2 ? 40 : 80 }));
await show({ schedule });
await edit();
await click("Harmonogram włączony", "switch");
expect(alerts()).toEqual([expect.stringContaining("Poziom przedziału 3 (40 %) jest niższy niż rezerwa (50 %).")]);
expect(screen.getByRole("button", { name: "Zapisz" })).toBeDisabled();
});
it("requires the starts to increase from slot 1 to 6", async () => {
await show();
await edit();
fireEvent.change(screen.getByLabelText("Początek przedziału 2"), { target: { value: "01:00" } });
expect(alerts()).toEqual(["Godziny początku muszą rosnąć od przedziału 1 do 6."]);
expect(screen.getByRole("button", { name: "Zapisz" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("Początek przedziału 2"), { target: { value: "01:01" } });
expect(alerts()).toEqual([]);
expect(screen.getByRole("button", { name: "Zapisz" })).toBeEnabled();
});
it("requires every start to be filled in", async () => {
await show();
await edit();
fireEvent.change(screen.getByLabelText("Początek przedziału 5"), { target: { value: "" } });
expect(alerts()).toEqual(["Podaj godzinę początku przedziału 5."]);
expect(screen.getByRole("button", { name: "Zapisz" })).toBeDisabled();
});
it("lets the last slot run to the start of the first", async () => {
await show();
await edit();
expect(within(screen.getByRole("group", { name: /^Przedział 6/ })).getByText("(do 01:00)")).toBeInTheDocument();
});
it("is not offered when the gateway takes no changes or the settings are stale", async () => {
await show({ editable: false, reserve: null });
expect(screen.queryByRole("button", { name: "Edytuj" })).toBeNull();
});
it("offers nothing to edit while the settings are stale", async () => {
await show({ mode: null, schedule: [], limits: null, capacityKwh: null, settingsStale: true,
settingsTime: "2026-10-23T21:00:00Z" });
expect(screen.queryByRole("button", { name: "Edytuj" })).toBeNull();
expect(screen.queryByLabelText("Mniej: rezerwa na awarię")).not.toBeNull();
});
});
describe("Battery write results", () => {
const cases: [string, number, object, RegExp][] = [
["rejected", 422, { status: "REJECTED", message: "x" }, /Nic nie zostało zmienione\./],
["changed meanwhile", 409, { status: "STALE", message: "x" }, /zmieniły się w międzyczasie.*Nic nie zostało zmienione/],
["too soon", 429, { status: "TOO_SOON", message: "x" }, /Za wcześnie po poprzednim zapisie/],
["failed", 502, { status: "FAILED", message: "x", read: { "167": 80 } },
/Nie wiadomo, czy zmiana doszła do falownika\..*Odczyt z falownika: 167 = 80\./],
];
it.each(cases)("says in Polish what a %s answer means", async (_, status, body, text) => {
await show({}, () => answer(body, status));
await edit();
await click("Harmonogram włączony", "switch");
await click("Zapisz");
await click("Tak, zapisz");
expect(await screen.findByText(text)).toBeInTheDocument();
});
it("explains a bad request and an unavailable gateway, and never retries", async () => {
for (const [status, text] of [[400, /Nieprawidłowa wartość\. Nic nie zostało zmienione\./],
[503, /niedostępne\. Nic nie zostało zmienione\./]] as const) {
const fetch = await show({}, () => answer({ error: "x" }, status));
await less("rezerwa na awarię");
await click("Zapisz");
await click("Tak, zapisz");
expect(await screen.findByText(text)).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(1);
document.body.innerHTML = "";
}
});
it("never claims nothing changed when the answer is lost", async () => {
const fetch = await show({}, () => Promise.reject(new TypeError("network")));
await less("rezerwa na awarię");
await click("Zapisz");
await click("Tak, zapisz");
expect(await screen.findByText(/Nie wiadomo, czy zmiana doszła do falownika/)).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(1);
});
});
+300
View File
@@ -0,0 +1,300 @@
import { useState, type ReactNode } from "react";
import { postJson, WriteError } from "./api";
import { percent } from "./format";
import { reload } from "./poll";
import type { Battery, BatteryChange, BatteryResult, BatterySlot } from "./types";
import { Button, Card, Pill, Row, Stepper } from "./ui";
const URL = "api/battery";
// The gateway polls the settings again 3 s after a write; the backend shows them from then on.
const SETTLE_MS = 5000;
// Taps move the level in fives; the bounds are always reachable because they clamp.
const SOC_STEP = 5;
export type Notice = { ok: boolean; text: string } | null;
const RESULT_TEXT: Record<BatteryResult["status"], string> = {
APPLIED: "Zapisano. Odczyt ustawień z falownika odświeży się za chwilę.",
REJECTED: "Falownik nie dostał tej zmiany, bo nie spełnia zasad. Nic nie zostało zmienione.",
STALE: "Ustawienia baterii zmieniły się w międzyczasie (np. w aplikacji Solarman) albo nie są aktualne. Nic nie zostało zmienione: poczekaj na odświeżenie i spróbuj ponownie.",
TOO_SOON: "Za wcześnie po poprzednim zapisie. Nic nie zostało zmienione, spróbuj za minutę.",
FAILED: "Nie wiadomo, czy zmiana doszła do falownika. Sprawdź ustawienia poniżej, zanim spróbujesz ponownie.",
};
const ERROR_TEXT: Record<number, string> = {
400: "Nieprawidłowa wartość. Nic nie zostało zmienione.",
503: "Zmiany ustawień baterii są teraz niedostępne. Nic nie zostało zmienione.",
};
// A write that loses its answer may still have reached the inverter.
export const WRITE_LOST = "Dom nie odpowiada. Nie wiadomo, czy zmiana doszła do falownika: sprawdź ustawienia, gdy połączenie wróci.";
/** Sends one change and says what came of it; a refusal, a conflict and a failed write are answers, not errors. */
async function submit(path: string, body: unknown): Promise<Notice> {
try {
const result = await postJson<BatteryResult>(`${URL}/${path}`, body);
const read = Object.entries(result.read ?? {}).map(([register, value]) => `${register} = ${value}`).join(", ");
const detail = result.status === "FAILED" && read ? ` Odczyt z falownika: ${read}.` : "";
return { ok: result.status === "APPLIED", text: RESULT_TEXT[result.status] + detail };
} catch (error) {
return { ok: false, text: ERROR_TEXT[error instanceof WriteError ? error.status : 0] ?? WRITE_LOST };
}
}
function refreshSoon() {
reload(URL);
setTimeout(() => reload(URL), SETTLE_MS);
}
/** The summary of a change with "Tak, zapisz" and a way back. */
function Confirm({ children, busy, onConfirm, onBack }: {
children: ReactNode; busy: boolean; onConfirm: () => void; onBack: () => void;
}) {
return (
<div role="group" aria-label="Potwierdzenie zmiany" className="mt-3 rounded-xl border-2 border-brand bg-brand-soft p-4">
<div className="text-lg font-semibold">{children}</div>
<div className="mt-3 flex gap-3">
<Button primary onClick={onConfirm} disabled={busy}>Tak, zapisz</Button>
<Button onClick={onBack} disabled={busy}>Wróć</Button>
</div>
</div>
);
}
function Switch({ label, checked, disabled, onChange }: {
label: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void;
}) {
return (
<button role="switch" aria-checked={checked} aria-label={label} disabled={disabled} onClick={() => onChange(!checked)}
className="flex h-12 w-16 shrink-0 items-center justify-center disabled:opacity-40">
<span className={`flex h-8 w-14 items-center rounded-full border-2 border-brand px-0.5 transition ${checked ? "justify-end bg-brand" : "justify-start bg-panel"}`}>
<span className={`size-6 rounded-full ${checked ? "bg-panel" : "bg-brand"}`} />
</span>
</button>
);
}
const RESERVE_BREAKS_SCHEDULE = "Harmonogram jest włączony, a któryś przedział ma poziom niższy niż nowa rezerwa. Najpierw podnieś poziomy w harmonogramie albo go wyłącz.";
export function Reserve({ b, onDone }: { b: Battery; onDone: (notice: Notice) => void }) {
const reserve = b.reserve!;
const [draft, setDraft] = useState<number | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const lowestSlot = Math.min(...b.schedule.map((slot) => slot.socPct));
// The gateway refuses it too; asking first keeps the phone from a pointless round trip.
const breaksSchedule = draft != null && !!b.mode?.touEnabled && lowestSlot < draft;
const stop = () => { setDraft(null); setConfirming(false); };
const step = (direction: number) => {
const next = Math.min(100, Math.max(reserve.floorPct, (draft ?? reserve.pct) + direction * SOC_STEP));
setDraft(next === reserve.pct ? null : next);
setConfirming(false);
};
async function save() {
setBusy(true);
const notice = await submit("reserve", { pct: draft });
setBusy(false);
stop();
onDone(notice);
window.scrollTo(0, 0);
refreshSoon();
}
return (
<Card className="lg:col-span-2">
<Row label="Rezerwa na awarię" value={percent(reserve.pct)} />
<p className="mt-2 text-sm text-soft">
Bateria nie rozładuje się poniżej tej wartości na co dzień; przy braku prądu zostaje dla domu.
</p>
{b.editable && (
<>
<Stepper label="rezerwa na awarię" value={draft ?? reserve.pct} min={reserve.floorPct} max={100}
format={percent} disabled={busy} onStep={step} />
<p className="text-sm text-soft">od {percent(reserve.floorPct)} do {percent(100)}</p>
{breaksSchedule && <p role="alert" className="mt-2 font-semibold text-alarm">{RESERVE_BREAKS_SCHEDULE}</p>}
{draft != null && !confirming && (
<div className="mt-3 flex gap-3">
<Button primary onClick={() => setConfirming(true)} disabled={busy || breaksSchedule}>Zapisz</Button>
<Button onClick={stop} disabled={busy}>Anuluj</Button>
</div>
)}
{draft != null && confirming && (
<Confirm busy={busy} onConfirm={save} onBack={() => setConfirming(false)}>
Rezerwa na awarię: z {percent(reserve.pct)} na <b>{percent(draft)}</b>
</Confirm>
)}
</>
)}
</Card>
);
}
interface Draft {
enabled: boolean;
slots: { from: string; socPct: number; gridCharge: boolean }[];
}
const draftOf = (b: Battery): Draft => ({
enabled: b.mode!.touEnabled,
slots: b.schedule.map(({ from, socPct, gridCharge }) => ({ from, socPct, gridCharge })),
});
const yesNo = (value: boolean) => (value ? "tak" : "nie");
/** Everything wrong with the draft, in words; the same rules the gateway enforces. */
function problemsOf(draft: Draft, reserve: number): string[] {
const problems: string[] = [];
draft.slots.forEach((slot, i) => {
if (!slot.from) problems.push(`Podaj godzinę początku przedziału ${i + 1}.`);
if (draft.enabled && slot.socPct < reserve) {
problems.push(`Poziom przedziału ${i + 1} (${percent(slot.socPct)}) jest niższy niż rezerwa (${percent(reserve)}).`);
}
});
const starts = draft.slots.map((slot) => slot.from);
if (starts.every(Boolean) && starts.some((from, i) => i > 0 && from <= starts[i - 1])) {
problems.push("Godziny początku muszą rosnąć od przedziału 1 do 6.");
}
return problems;
}
/** The change as the backend takes it and as a sentence per line; empty when nothing differs. */
function changesOf(b: Battery, draft: Draft): { body: BatteryChange; lines: string[] } {
const lines: string[] = [];
const body: BatteryChange = { slots: [] };
if (draft.enabled !== b.mode!.touEnabled) {
body.enabled = draft.enabled;
lines.push(`Harmonogram: ${draft.enabled ? "włączony" : "wyłączony"}`);
}
draft.slots.forEach((slot, i) => {
const old: BatterySlot = b.schedule[i];
const change: BatteryChange["slots"][number] = { slot: i + 1 };
const parts: string[] = [];
if (slot.from !== old.from) { change.from = slot.from; parts.push(`początek ${old.from} → ${slot.from}`); }
if (slot.socPct !== old.socPct) { change.socPct = slot.socPct; parts.push(`poziom ${percent(old.socPct)} → ${percent(slot.socPct)}`); }
if (slot.gridCharge !== old.gridCharge) {
change.gridCharge = slot.gridCharge;
parts.push(`ładowanie z sieci ${yesNo(old.gridCharge)} → ${yesNo(slot.gridCharge)}`);
}
if (parts.length) {
body.slots.push(change);
lines.push(`Przedział ${i + 1}: ${parts.join(", ")}`);
}
});
return { body, lines };
}
function SlotEditor({ n, slot, until, min, disabled, onChange }: {
n: number; slot: Draft["slots"][number]; until: string; min: number; disabled: boolean;
onChange: (slot: Draft["slots"][number]) => void;
}) {
return (
<fieldset className="rounded-xl border border-edge p-3">
<legend className="px-1 font-semibold">Przedział {n}{slot.from && until ? <span className="font-normal text-soft"> (do {until})</span> : null}</legend>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<label className="flex items-center gap-2 text-soft">
Początek
<input type="time" required value={slot.from} disabled={disabled} aria-label={`Początek przedziału ${n}`}
onChange={(event) => onChange({ ...slot, from: event.target.value })}
className="min-h-12 rounded-xl border-2 border-edge bg-panel px-3 text-lg font-semibold text-ink" />
</label>
<div className="min-w-48 flex-1">
<Stepper compact label={`poziom, przedział ${n}`} value={slot.socPct} min={min} max={100} format={percent}
disabled={disabled} onStep={(direction) => onChange({ ...slot,
socPct: Math.min(100, Math.max(min, slot.socPct + direction * SOC_STEP)) })} />
</div>
<div className="flex items-center gap-1 text-soft">
Ładowanie z sieci
<Switch label={`Ładowanie z sieci, przedział ${n}`} checked={slot.gridCharge} disabled={disabled}
onChange={(gridCharge) => onChange({ ...slot, gridCharge })} />
</div>
</div>
</fieldset>
);
}
function ScheduleEditor({ b, onClose, onDone }: { b: Battery; onClose: () => void; onDone: (notice: Notice) => void }) {
const [draft, setDraft] = useState(() => draftOf(b));
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const reserve = b.reserve!.pct;
const problems = problemsOf(draft, reserve);
const { body, lines } = changesOf(b, draft);
const edit = (next: Draft) => { setDraft(next); setConfirming(false); };
async function save() {
setBusy(true);
const notice = await submit("schedule", body);
setBusy(false);
onDone(notice);
onClose();
window.scrollTo(0, 0);
refreshSoon();
}
return (
<Card title="Harmonogram: edycja" className="lg:col-span-2">
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">Harmonogram włączony</span>
<Switch label="Harmonogram włączony" checked={draft.enabled} disabled={busy}
onChange={(enabled) => edit({ ...draft, enabled })} />
</div>
<div className="mt-3 grid gap-3">
{draft.slots.map((slot, i) => (
<SlotEditor key={i} n={i + 1} slot={slot} until={draft.slots[(i + 1) % draft.slots.length].from}
min={reserve} disabled={busy}
onChange={(next) => edit({ ...draft, slots: draft.slots.map((s, j) => (j === i ? next : s)) })} />
))}
</div>
<p className="mt-2 text-sm text-soft">Poziom nie może być niższy niż rezerwa na awarię ({percent(reserve)}). Ostatni przedział trwa do początku pierwszego.</p>
{problems.length > 0 && (
<ul role="alert" className="mt-2 list-disc pl-5 font-semibold text-alarm">
{problems.map((problem) => <li key={problem}>{problem}</li>)}
</ul>
)}
{!confirming && (
<div className="mt-3 flex gap-3">
<Button primary onClick={() => setConfirming(true)} disabled={busy || problems.length > 0 || lines.length === 0}>Zapisz</Button>
<Button onClick={onClose} disabled={busy}>Anuluj</Button>
</div>
)}
{confirming && (
<Confirm busy={busy} onConfirm={save} onBack={() => setConfirming(false)}>
<p>Zmieniam w falowniku:</p>
<ul className="list-disc pl-5 text-base">{lines.map((line) => <li key={line}>{line}</li>)}</ul>
</Confirm>
)}
</Card>
);
}
/** The schedule table of the Bateria view; with an "Edytuj" action when the gateway takes changes. */
export function Schedule({ b, onDone }: { b: Battery; onDone: (notice: Notice) => void }) {
const [editing, setEditing] = useState(false);
const off = b.mode ? !b.mode.touEnabled : false;
if (editing && b.editable && b.reserve && b.mode) {
return <ScheduleEditor b={b} onClose={() => setEditing(false)} onDone={onDone} />;
}
const edit = b.editable && b.reserve ? <Button onClick={() => setEditing(true)}>Edytuj</Button> : null;
return (
<Card title="Harmonogram" aside={<div className="flex items-center gap-2">{off && <Pill tone="off">wyłączony</Pill>}{edit}</div>}>
<table className={`w-full text-[0.95rem] ${off ? "opacity-50" : ""}`}>
<caption className="sr-only">Przedziały pracy baterii{off ? " (wyłączone)" : ""}</caption>
<thead>
<tr className="text-left text-sm text-soft">
<th scope="col" className="pb-2 font-medium">Godziny</th>
<th scope="col" className="pb-2 font-medium">Poziom</th>
<th scope="col" className="pb-2 text-right font-medium">Ładowanie z sieci</th>
</tr>
</thead>
<tbody>
{b.schedule.map((slot) => (
<tr key={slot.from} className="border-t border-edge">
<th scope="row" className="py-2 text-left font-semibold tabular-nums">{slot.from}–{slot.to}</th>
<td className="py-2 tabular-nums">{percent(slot.socPct)}</td>
<td className="py-2 text-right font-semibold">{slot.gridCharge ? "tak" : "nie"}</td>
</tr>
))}
</tbody>
</table>
</Card>
);
}
+73 -3
View File
@@ -1,6 +1,6 @@
import { cleanup, render, screen, within } from "@testing-library/react";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { Energy as EnergyData, EnergyDetails } from "./types";
@@ -8,12 +8,79 @@ import type { Energy as EnergyData, EnergyDetails } from "./types";
const energy = async () => (await import("./Energy")).Energy;
function serveEnergy(details: EnergyDetails = example("energy-details")) {
serve({ "api/energy/details": details, "api/energy": example("energy") });
serve({ "api/energy/details": details, "api/energy": example("energy"), "api/battery": example("battery") });
}
beforeEach(() => {
location.hash = "";
});
describe("Energy views", () => {
const tab = (name: string) => screen.getByRole("tab", { name });
it("starts on Teraz with three tabs, each at least 44 px high", async () => {
serveEnergy();
const Energy = await energy();
render(<Energy />);
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Teraz", "Bateria", "Falowniki"]);
expect(tab("Teraz")).toHaveAttribute("aria-selected", "true");
expect(tab("Teraz")).toHaveClass("min-h-11");
expect(await screen.findByText("Energia teraz")).toBeInTheDocument();
expect(screen.queryByText("Urządzenia")).toBeNull();
});
it("switches views and keeps each in the URL hash", async () => {
serveEnergy();
const Energy = await energy();
render(<Energy />);
await userEvent.click(tab("Bateria"));
expect(location.hash).toBe("#energy/battery");
expect(tab("Bateria")).toHaveAttribute("aria-selected", "true");
expect(await screen.findByText("Harmonogram")).toBeInTheDocument();
await userEvent.click(tab("Falowniki"));
expect(location.hash).toBe("#energy/inverters");
expect(await screen.findByRole("button", { name: /Falownik Deye.*Szczegóły/ })).toBeInTheDocument();
await userEvent.click(tab("Teraz"));
expect(location.hash).toBe("#energy");
});
it("opens the view named by the hash and Back returns to the previous one", async () => {
serveEnergy();
location.hash = "energy/inverters";
const Energy = await energy();
render(<Energy />);
expect(tab("Falowniki")).toHaveAttribute("aria-selected", "true");
await userEvent.click(tab("Bateria"));
expect(tab("Bateria")).toHaveAttribute("aria-selected", "true");
history.back();
await waitFor(() => expect(tab("Falowniki")).toHaveAttribute("aria-selected", "true"));
});
it("opens Bateria when the battery in the flow diagram is tapped, and Back returns to Teraz", async () => {
serveEnergy();
const Energy = await energy();
render(<Energy />);
await userEvent.click(await screen.findByRole("button", { name: /Bateria.*szczegóły/ }));
expect(tab("Bateria")).toHaveAttribute("aria-selected", "true");
expect(location.hash).toBe("#energy/battery");
history.back();
await waitFor(() => expect(tab("Teraz")).toHaveAttribute("aria-selected", "true"));
expect(await screen.findByText("Energia teraz")).toBeInTheDocument();
});
});
describe("Energy details", () => {
it("offers every device with a visible way to open it", async () => {
serveEnergy();
location.hash = "energy/inverters";
const Energy = await energy();
render(<Energy />);
@@ -25,6 +92,7 @@ describe("Energy details", () => {
it("opens the groups of a device and closes by button, backdrop and Escape", async () => {
serveEnergy();
location.hash = "energy/inverters";
const Energy = await energy();
render(<Energy />);
@@ -52,6 +120,7 @@ describe("Energy details", () => {
it("stays open when the panel itself is tapped", async () => {
serveEnergy();
location.hash = "energy/inverters";
const Energy = await energy();
render(<Energy />);
@@ -64,6 +133,7 @@ describe("Energy details", () => {
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)) });
location.hash = "energy/inverters";
const Energy = await energy();
render(<Energy />);
+75 -16
View File
@@ -1,5 +1,7 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState, type KeyboardEvent } from "react";
import { Cell, Pie, PieChart } from "recharts";
import { Battery } from "./Battery";
import { flowing } from "./flow";
import { kwh, percent, power, when } from "./format";
import { Icon, type IconName } from "./icons";
import { usePoll } from "./poll";
@@ -7,7 +9,6 @@ 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;
const INVERTERS = { deye: "Falownik Deye", fronius: "Falownik Fronius" } as const;
/** Inverters without a fresh reading, in plain words. A source the backend does not list is missing too. */
@@ -19,7 +20,6 @@ export function silentInverters(e: EnergyData) {
});
}
const flowing = (watts: number | null) => watts != null && Math.abs(watts) >= FLOWING_W;
const magnitude = (watts: number | null) => (watts == null ? null : Math.abs(watts));
// The SDM630 and Deye sign: + the house takes from the grid, − it gives back.
@@ -58,7 +58,7 @@ function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof
// Faster dots for more power: 2.5 s per run at 10 W, down to 0.6 s at 5 kW and more.
const speed = on ? Math.max(0.6, 2.5 - (Math.min(Math.abs(watts!), 5000) / 5000) * 1.9) : 0;
return (
<g data-flow={`${from}-${to}`} data-on={on}>
<g data-flow={`${from}-${to}`} data-on={on} aria-hidden="true">
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 5 : 3} strokeLinecap="round" opacity={on ? 0.35 : 0.6} />
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={5} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
{on && <Arrowhead from={s} to={e} />}
@@ -66,15 +66,26 @@ function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof
);
}
function Node({ id, value, note }: { id: keyof typeof NODES; value: string; note?: string }) {
// With onOpen the node is a button: the chevron says it leads somewhere.
function Node({ id, value, note, onOpen }: { id: keyof typeof NODES; value: string; note?: string; onOpen?: () => void }) {
const { x, y, label, icon, colour } = NODES[id];
const button = onOpen && {
role: "button", tabIndex: 0, "aria-label": `${label}: pokaż szczegóły`, onClick: onOpen, className: "cursor-pointer outline-offset-2 focus-visible:outline-2 focus-visible:outline-brand",
onKeyDown: (event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen();
}
},
};
return (
<g>
<g aria-hidden={onOpen ? undefined : true} {...button}>
<rect x={x - 78} y={y - 40} width={156} height={80} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={1.5} />
<g transform={`translate(${x - 64} ${y - 26})`} color={colour}><Icon name={icon} size={28} /></g>
<text x={x - 28} y={y - 10} fontSize={14} className="scene-soft">{label}</text>
<text x={x - 28} y={y + 16} fontSize={22} fontWeight={700} className="scene-text">{value}</text>
{note && <text x={x - 28} y={y + 33} fontSize={12} className="scene-soft">{note}</text>}
{onOpen && <text x={x + 62} y={y + 8} fontSize={28} textAnchor="middle" className="scene-soft" aria-hidden="true">›</text>}
</g>
);
}
@@ -91,18 +102,18 @@ export function flowSummary(e: EnergyData) {
];
}
function FlowPicture({ e }: { e: EnergyData }) {
function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void }) {
return (
<>
<ul className="sr-only">{flowSummary(e).map((t) => <li key={t}>{t}</li>)}</ul>
<svg viewBox="0 0 600 350" className="h-auto w-full" aria-hidden="true">
<svg viewBox="0 0 600 350" className="h-auto w-full">
<Flow from="pv" to="house" watts={e.pvPowerW} />
<Flow from="grid" to="house" watts={e.gridPowerW} />
<Flow from="battery" to="house" watts={e.batteryPowerW} />
<Node id="pv" value={power(e.pvPowerW)} />
<Node id="grid" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Node id="house" value={power(e.loadPowerW)} note="zużycie" />
<Node id="battery" value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
<Node id="battery" onOpen={onBattery} value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
</svg>
</>
);
@@ -169,7 +180,7 @@ function Devices() {
const close = useCallback(() => setOpenId(null), []);
const open = data?.devices.find((d) => d.id === openId);
return (
<Card title="Urządzenia" className="lg:col-span-3">
<Card title="Urządzenia">
{!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)} />)}
@@ -180,18 +191,16 @@ function Devices() {
);
}
export function Energy() {
function Now({ onBattery }: { onBattery: () => void }) {
const { data: e, offline } = usePoll<EnergyData>("api/energy");
const title = <PageTitle title="Energia" subtitle="Falowniki Deye i Fronius, licznik domu" />;
if (!e) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
if (!e) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
const missing = silentInverters(e);
return (
<>
{title}
<Freshness offline={offline} time={e.time} />
{missing.length > 0 && <Banner tone="warn">{missing.map((m) => <div key={m.label}>{m.label}: {m.since}</div>)}</Banner>}
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Energia teraz" className="lg:col-span-2"><FlowPicture e={e} /></Card>
<Card title="Energia teraz" className="lg:col-span-2"><FlowPicture e={e} onBattery={onBattery} /></Card>
<EnergyToday e={e} />
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3 lg:grid-cols-4">
<Stat icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
@@ -199,7 +208,57 @@ 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>
</>
);
}
const VIEWS = { now: "Teraz", battery: "Bateria", inverters: "Falowniki" } as const;
type ViewId = keyof typeof VIEWS;
// #energy is "Teraz", #energy/battery and #energy/inverters the others: every view is a history entry, so Back returns.
function viewFromHash(): ViewId {
const [screen, view = ""] = location.hash.slice(1).split("/");
return screen === "energy" && view in VIEWS ? (view as ViewId) : "now";
}
function useView() {
const [view, setView] = useState(viewFromHash);
useEffect(() => {
const onHash = () => setView(viewFromHash());
addEventListener("hashchange", onHash);
return () => removeEventListener("hashchange", onHash);
}, []);
const show = (id: ViewId) => {
location.hash = id === "now" ? "energy" : `energy/${id}`;
};
return [view, show] as const;
}
function ViewSwitch({ view, onSelect }: { view: ViewId; onSelect: (id: ViewId) => void }) {
return (
<div role="tablist" aria-label="Widok energii" className="mb-5 flex gap-1 rounded-xl border border-edge bg-panel p-1 lg:max-w-md">
{(Object.entries(VIEWS) as [ViewId, string][]).map(([id, label]) => (
<button key={id} role="tab" id={`energy-tab-${id}`} aria-selected={id === view} aria-controls="energy-view"
onClick={() => onSelect(id)}
className="min-h-11 flex-1 rounded-lg px-3 font-semibold text-soft focus-visible:outline-2 focus-visible:outline-brand aria-selected:bg-brand aria-selected:text-white dark:aria-selected:text-black">
{label}
</button>
))}
</div>
);
}
export function Energy() {
const [view, show] = useView();
return (
<>
<PageTitle title="Energia" subtitle="Falowniki Deye i Fronius, licznik domu" />
<ViewSwitch view={view} onSelect={show} />
<div role="tabpanel" id="energy-view" aria-labelledby={`energy-tab-${view}`}>
{view === "now" && <Now onBattery={() => show("battery")} />}
{view === "battery" && <Battery />}
{view === "inverters" && <Devices />}
</div>
</>
);
+3 -33
View File
@@ -3,7 +3,7 @@ import { postJson, WriteError } from "./api";
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
import { reload, usePoll } from "./poll";
import type { Change, CommandResult, LineStatus, Preset, RestoreOutcome, RestoreResult, Setpoint, Setpoints } from "./types";
import { Card, Freshness, Loading, PageTitle } from "./ui";
import { Button, Card, Freshness, Loading, PageTitle, Stepper } from "./ui";
const URL = "api/setpoints";
const NAMES: Record<string, string> = {
@@ -54,36 +54,6 @@ const presetReady = (data: Setpoints, changes: Change[]) => changes.every((c) =>
const returnValue = (data: Setpoints, id: string) =>
data.restores.find((r) => r.setpoint === id)?.originalValue ?? find(data, id).value;
function Button({ primary = false, big = false, children, ...props }: {
primary?: boolean; big?: boolean; children: ReactNode; onClick: () => void; disabled?: boolean;
"aria-label"?: string; "aria-pressed"?: boolean;
}) {
const look = primary
? "border-brand bg-brand text-white dark:text-side"
: "border-brand bg-panel text-brand aria-pressed:bg-brand aria-pressed:text-white dark:aria-pressed:text-side";
return (
<button {...props}
className={`flex-1 rounded-xl border-2 px-3 font-bold transition active:scale-[0.98] disabled:opacity-40 ${look} ${big ? "min-h-16 text-lg" : "min-h-12"}`}>
{children}
</button>
);
}
// Touch-sized − and + around the value; no value shows "brak odczytu".
function Stepper({ label, value, min, max, disabled, onStep }: {
label: string; value: number | null | undefined; min: number; max: number; disabled?: boolean; onStep: (direction: number) => void;
}) {
const none = value == null;
const button = "size-16 rounded-xl border-2 border-brand bg-panel text-3xl font-bold text-brand disabled:opacity-40";
return (
<div className="my-2 flex items-center gap-3">
<button aria-label={`Mniej: ${label}`} onClick={() => onStep(-1)} disabled={disabled || none || value <= min} className={button}>−</button>
<output className="flex-1 text-center text-3xl font-bold tabular-nums">{none ? "brak odczytu" : degrees(value)}</output>
<button aria-label={`Więcej: ${label}`} onClick={() => onStep(1)} disabled={disabled || none || value >= max} className={button}>+</button>
</div>
);
}
function ResultBox({ result }: { result: Result }) {
if (!result) return null;
const ok = result.status === "APPLIED";
@@ -159,7 +129,7 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
return (
<div key={c.setpoint} className="my-3">
<span className="block font-semibold">{label}</span>
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} disabled={busy || max < min}
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} format={degrees} disabled={busy || max < min}
onStep={(direction) => stepValue(c.setpoint, direction)} />
{max < min && <p className="text-sm text-alarm">Już na minimum ({degrees(find(data, c.setpoint).value)}), nie da się obniżyć.</p>}
</div>
@@ -328,7 +298,7 @@ export function Settings() {
return (
<div key={s.name} className={`border-t border-edge py-3 first:border-t-0 ${shown === s.value ? "" : "border-l-4 border-l-brand pl-3"}`}>
<span className="block font-semibold">{label}</span>
<Stepper label={label} value={unread(s) ? null : shown} min={s.min} max={s.max} disabled={busy} onStep={(direction) => step(s, direction)} />
<Stepper label={label} value={unread(s) ? null : shown} min={s.min} max={s.max} format={degrees} disabled={busy} onStep={(direction) => step(s, direction)} />
<span className="text-sm text-soft">od {degrees(s.min)} do {degrees(s.max)}</span>
</div>
);
+4
View File
@@ -0,0 +1,4 @@
// Below this a flow of energy is noise: the diagram and the battery view call it "none".
const FLOWING_W = 10;
export const flowing = (watts: number | null) => watts != null && Math.abs(watts) >= FLOWING_W;
+1
View File
@@ -15,6 +15,7 @@ export function number(value: Value, digits = 0): string {
export const celsius = (value: Value) => (value == null ? DASH : `${number(value, 1)} °C`);
export const percent = (value: Value) => (value == null ? DASH : `${number(value)} %`);
export const kwh = (value: Value) => (value == null ? DASH : `${number(value, 1)} kWh`);
export const amps = (value: Value) => (value == null ? DASH : `${number(value)} A`);
// "45 °C" for a whole setpoint, "45,5 °C" otherwise.
export const degrees = (value: Value) => (value == null ? DASH : `${number(value, 1).replace(/,0$/, "")} °C`);
+56
View File
@@ -45,6 +45,62 @@ export interface Energy {
today: { pvKwh: number | null; gridBoughtKwh: number | null; gridSoldKwh: number | null; loadKwh: number | null };
}
export interface BatteryEnergy {
chargeKwh: number | null;
dischargeKwh: number | null;
}
export interface BatterySlot {
from: string;
to: string;
powerW: number;
socPct: number;
gridCharge: boolean;
}
export interface Battery {
time: string | null;
stale: boolean;
socPct: number | null;
powerW: number | null; // + discharge / − charge
voltageV: number | null;
currentA: number | null;
temperatureC: number | null;
capacityKwh: number | null;
today: BatteryEnergy;
total: BatteryEnergy;
// The settings are null (and the schedule empty) when their own reading is stale.
mode: { touEnabled: boolean; description: string } | null;
schedule: BatterySlot[];
limits: {
maxChargeA: number;
maxDischargeA: number;
shutdownSocPct: number;
lowSocPct: number;
restartSocPct: number;
gridChargeEnabled: boolean;
gridChargeCurrentA: number;
gridChargeStartSocPct: number;
} | null;
settingsTime: string | null;
settingsStale: boolean;
// The app's backup reserve (ADR-013); null, with editable false, when the gateway takes no battery changes.
reserve: { pct: number; floorPct: number } | null;
editable: boolean;
}
export interface BatteryResult {
status: "APPLIED" | "REJECTED" | "STALE" | "TOO_SOON" | "FAILED";
message?: string;
read?: Record<string, number>;
}
/** What POST api/battery/schedule takes: only what changes. */
export interface BatteryChange {
enabled?: boolean;
slots: { slot: number; from?: string; socPct?: number; gridCharge?: boolean }[];
}
export interface EnergyDetailsItem {
label: string;
value: number;
+31
View File
@@ -108,3 +108,34 @@ export function PageTitle({ title, subtitle, aside }: { title: string; subtitle?
</header>
);
}
export function Button({ primary = false, big = false, children, ...props }: {
primary?: boolean; big?: boolean; children: ReactNode; onClick: () => void; disabled?: boolean;
"aria-label"?: string; "aria-pressed"?: boolean;
}) {
const look = primary
? "border-brand bg-brand text-white dark:text-side"
: "border-brand bg-panel text-brand aria-pressed:bg-brand aria-pressed:text-white dark:aria-pressed:text-side";
return (
<button {...props}
className={`flex-1 rounded-xl border-2 px-3 font-bold transition active:scale-[0.98] disabled:opacity-40 ${look} ${big ? "min-h-16 text-lg" : "min-h-12"}`}>
{children}
</button>
);
}
/** Touch-sized − and + around the value; no value shows "brak odczytu". Compact is for a row of several. */
export function Stepper({ label, value, min, max, format, compact = false, disabled, onStep }: {
label: string; value: number | null | undefined; min: number; max: number; format: (value: number) => string;
compact?: boolean; disabled?: boolean; onStep: (direction: number) => void;
}) {
const none = value == null;
const button = `${compact ? "size-12 text-2xl" : "size-16 text-3xl"} rounded-xl border-2 border-brand bg-panel font-bold text-brand disabled:opacity-40`;
return (
<div className={`flex items-center gap-3 ${compact ? "" : "my-2"}`}>
<button aria-label={`Mniej: ${label}`} onClick={() => onStep(-1)} disabled={disabled || none || value <= min} className={button}>−</button>
<output className={`flex-1 text-center font-bold tabular-nums ${compact ? "min-w-16 text-xl" : "text-3xl"}`}>{none ? "brak odczytu" : format(value)}</output>
<button aria-label={`Więcej: ${label}`} onClick={() => onStep(1)} disabled={disabled || none || value >= max} className={button}>+</button>
</div>
);
}
+26
View File
@@ -37,6 +37,8 @@ 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.
Enabling `deye` also starts `deye-settings`: the battery limits and the six program slots, every 10 min over the same
logger link, into `electricity.deye_settings` (FC3 only, never written).
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),
@@ -59,6 +61,30 @@ Do all of this before setting `commands.enabled` to true:
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.
## Enabling battery commands
The Deye's program table and the backup reserve (ADR-013), off unless `deyeCommands.enabled`. It needs
`devices.deye.enabled` and the token file and firewall steps of "Enabling commands" (`commands.tokenFile` is
reused; `commands.enabled` may stay false). `deyeCommands`: `reserveFile` (`reserve.json` next to the
restore file, `{"pct": 50}`, replaced atomically; missing means 50, an unreadable file is logged and means 50),
`reserveFloorPct` (30; the reserve is never lower, nor below the inverter's shutdown SOC + 10),
`minIntervalSeconds` (60, per register) and `readBackDelayMillis` (1000). Same Bearer token and failed-login
throttle as `/commands`; `X-Requested-By` names the requester. While it is off, `/battery` answers 404.
- `GET /battery/reserve` answers `{"pct", "floorPct"}`; `POST /battery/reserve` `{"pct": 60}` changes it.
- `POST /battery/schedule` `{"enabled": true, "slots": [{"slot": 2, "start": 930, "soc": 90, "gridCharge": true}]}`;
every field is optional, slots are 1 to 6.
- 200 applied, 400 bad body, 422 rejected (no write), 409 the settings were not read in the last 30 minutes or
differ from that reading right before the write (nothing written), 429 too soon after the last write to a
register, 502 failed: the body's `read` holds the registers as read back, nothing is retried.
- Rules: SOC from the reserve to 100; grid charge written as 0 or 1 only; starts are HHMM, strictly increasing
from slot 1 to 6; an enabled schedule needs every slot SOC at or above the reserve; the reserve cannot be
raised above a slot SOC of an enabled schedule. The whole table is checked before anything is written,
neighbouring registers go in one FC16, the Time of Use switch last, the days (bits 1 to 7 of 146) stay as read.
- Audit: `electricity.deye_settings_audit` (tags `register`, `requested_by`, `status`; fields `old_value`,
`new_value`). After a write the settings are polled again 3 s later. No restore and no lease: these are
persistent settings, as in the Solarman app.
## Leases (ADR-011)
Every applied change, timed or "until changed", keeps a restore with a 15 minute lease. The restore runs at
+7
View File
@@ -24,6 +24,13 @@
{ "name": "cwu_comfort", "register": 4144, "min": 5, "max": 48, "step": 1 }
]
},
"deyeCommands": {
"enabled": false,
"reserveFile": "/home/pi/home-pump/reserve.json",
"reserveFloorPct": 30,
"minIntervalSeconds": 60,
"readBackDelayMillis": 1000
},
"devices": {
"heatPump": {
"enabled": true,
@@ -2,6 +2,8 @@ package home.gateway;
import home.gateway.adapters.bme280.Bme280Device;
import home.gateway.adapters.deye.DeyeDevice;
import home.gateway.adapters.deye.DeyeSettingsDevice;
import home.gateway.adapters.deye.ModbusBatteryRegisters;
import home.gateway.adapters.fronius.FroniusDevice;
import home.gateway.adapters.heatpump.HeatPumpDevice;
import home.gateway.adapters.heatpump.ModbusSetpointRegisters;
@@ -13,7 +15,10 @@ import home.gateway.adapters.modbus.PduModbusClient;
import home.gateway.adapters.modbus.TcpTransport;
import home.gateway.adapters.solarman.SolarmanV5Transport;
import home.gateway.adapters.store.CorruptRestoreFileException;
import home.gateway.adapters.store.JsonReserveStore;
import home.gateway.adapters.store.JsonRestoreStore;
import home.gateway.application.BatteryCommands;
import home.gateway.application.BatterySettings;
import home.gateway.application.CommandService;
import home.gateway.application.Commands;
import home.gateway.application.DisabledCommands;
@@ -61,6 +66,8 @@ public final class Gateway implements AutoCloseable {
private final Deque<AutoCloseable> resources = new ArrayDeque<>();
/** The one client of the controller: the MK01 drops a connection when a second one opens. */
private PduModbusClient heatPumpModbus;
/** The one client of the Deye's logger, shared by the pollers and the settings writes. */
private PduModbusClient deyeModbus;
private Gateway(GatewayConfig config, Clock clock, Runnable exit) {
this.config = config;
@@ -100,7 +107,8 @@ public final class Gateway implements AutoCloseable {
private void assemble() throws IOException {
List<Device> devices = devices();
requireCriticalDevicesEnabled(devices);
String token = config.commands().enabled() ? readToken(config.commands().tokenFile()) : null;
boolean writes = config.commands().enabled() || config.deyeCommands().enabled();
String token = writes ? readToken(config.commands().tokenFile()) : null;
InfluxWriter writer = new InfluxWriter(config.influx().settings());
scheduleFlush(writer);
@@ -117,9 +125,21 @@ public final class Gateway implements AutoCloseable {
if (commands != null) {
http.serveCommands(commands, token);
}
if (config.deyeCommands().enabled()) {
http.serveBattery(startBattery(snapshot, writer, poller), token);
}
http.start();
}
private BatteryCommands startBattery(Snapshot snapshot, InfluxWriter audit, Poller poller) {
var settings = config.deyeCommands();
return new BatterySettings(new ModbusBatteryRegisters(deyeModbus), snapshot,
new JsonReserveStore(settings.reserveFile()), settings.reserveFloorPct(), audit, clock,
System::nanoTime, settings.minInterval(),
() -> Thread.sleep(settings.readBackDelayMillis()),
() -> poller.requestPoll(BatterySettings.SETTINGS_DEVICE));
}
/**
* A restore file that cannot be read must not stop the heating side (polling, health), so the
* write path stays off instead, and never writes, until someone has looked at the moved file.
@@ -173,7 +193,10 @@ public final class Gateway implements AutoCloseable {
result.add(new HouseMeterDevice(new PduModbusClient(own(TcpTransport.rtuOverTcp(meterLink.settings())))));
}
if (Switchable.isOn(devices.deye())) {
result.add(new DeyeDevice(deyeModbus(devices.deye())));
// One client for both, so the logger sees one request at a time.
deyeModbus = deyeModbus(devices.deye());
result.add(new DeyeDevice(deyeModbus));
result.add(new DeyeSettingsDevice(deyeModbus));
}
if (Switchable.isOn(devices.fronius())) {
var fronius = devices.fronius();
@@ -0,0 +1,119 @@
package home.gateway.adapters.deye;
import home.gateway.adapters.modbus.ModbusClient;
import home.gateway.application.BatterySettings;
import home.gateway.application.Device;
import home.gateway.domain.Reading;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.IntToDoubleFunction;
/**
* The Deye's battery and program settings (FC3 only), which change rarely, so they are read every
* ten minutes to keep the logger's traffic low. Registers: research/energy/README.md.
*/
public final class DeyeSettingsDevice implements Device {
public static final String NAME = BatterySettings.SETTINGS_DEVICE;
private static final int UNIT_ID = 1;
private static final int PROGRAMS = 6;
private record Block(int address, int count) {
}
private record Field(String name, int register, IntToDoubleFunction decode) {
}
/** One request per block, in this order. */
private static final List<Block> BLOCKS = List.of(new Block(98, 20), new Block(127, 51), new Block(231, 3));
private static final int TIME_OF_USE = 146;
private static final int PROGRAM_TIME = 148;
private static final int PROGRAM_POWER = 154;
private static final int PROGRAM_VOLTAGE = 160;
private static final int PROGRAM_SOC = 166;
private static final int PROGRAM_SOURCE = 172;
private static final List<Field> FIELDS = List.of(
new Field("battery_type", 98, x -> x),
new Field("battery_capacity_ah", 102, x -> x),
new Field("zero_export_power_w", 104, x -> x),
new Field("max_charge_current_a", 108, x -> x),
new Field("max_discharge_current_a", 109, x -> x),
new Field("battery_mode", 111, x -> x),
new Field("shutdown_soc_pct", 115, x -> x),
new Field("restart_soc_pct", 116, x -> x),
new Field("low_soc_pct", 117, x -> x),
new Field("grid_charge_start_soc_pct", 127, x -> x),
new Field("grid_charge_current_a", 128, x -> x),
new Field("grid_charge_enabled", 130, x -> x),
new Field("energy_pattern", 141, x -> x),
new Field("work_mode", 142, x -> x),
new Field("max_sell_power_w", 143, x -> x),
new Field("export_surplus", 145, x -> x),
// Bit 0 is the switch, bits 1 to 7 are Monday to Sunday.
new Field("tou_enabled", TIME_OF_USE, x -> x & 1),
new Field("tou_days", TIME_OF_USE, x -> (x >> 1) & 0x7F),
// Scaling unverified, so stored raw and not shown.
new Field("grid_max_export_raw", 231, x -> x),
new Field("grid_max_import_raw", 233, x -> x));
private final ModbusClient modbus;
public DeyeSettingsDevice(ModbusClient modbus) {
this.modbus = modbus;
}
@Override
public String name() {
return NAME;
}
@Override
public Duration interval() {
return Duration.ofMinutes(10);
}
@Override
public List<Reading> read(Instant now) throws IOException {
Map<Integer, Integer> words = readBlocks();
Map<String, Double> fields = new LinkedHashMap<>();
for (Field field : FIELDS) {
fields.put(field.name(), field.decode().applyAsDouble(words.get(field.register())));
}
for (int slot = 0; slot < PROGRAMS; slot++) {
String prefix = "prog" + (slot + 1) + "_";
fields.put(prefix + "start_hhmm", (double) words.get(PROGRAM_TIME + slot));
fields.put(prefix + "power_w", (double) words.get(PROGRAM_POWER + slot));
fields.put(prefix + "voltage_v", words.get(PROGRAM_VOLTAGE + slot) / 100.0);
fields.put(prefix + "soc_pct", (double) words.get(PROGRAM_SOC + slot));
int source = words.get(PROGRAM_SOURCE + slot);
// 1 is the grid, 3 the grid and the generator.
fields.put(prefix + "grid_charge", source == 1 || source == 3 ? 1.0 : 0.0);
}
return List.of(new Reading("electricity", "deye_settings", Map.of("location", "dom-szczepancowa"),
fields, now));
}
private Map<Integer, Integer> readBlocks() throws IOException {
Map<Integer, Integer> words = new HashMap<>();
for (Block block : BLOCKS) {
int[] values = modbus.readHoldingRegisters(UNIT_ID, block.address(), block.count());
if (values.length != block.count()) {
throw new IOException("Deye settings block " + block.address() + " returned "
+ values.length + " registers, expected " + block.count());
}
for (int i = 0; i < values.length; i++) {
words.put(block.address() + i, values[i]);
}
}
return words;
}
}
@@ -0,0 +1,32 @@
package home.gateway.adapters.deye;
import home.gateway.adapters.modbus.ModbusClient;
import home.gateway.application.BatteryRegisters;
import java.io.IOException;
/** The Deye's holding registers over the shared logger link: FC3 to read, FC16 to write. */
public final class ModbusBatteryRegisters implements BatteryRegisters {
private static final int UNIT_ID = 1;
private final ModbusClient modbus;
public ModbusBatteryRegisters(ModbusClient modbus) {
this.modbus = modbus;
}
@Override
public int[] read(int address, int count) throws IOException {
int[] words = modbus.readHoldingRegisters(UNIT_ID, address, count);
if (words.length != count) {
throw new IOException("Expected " + count + " registers from " + address + ", got " + words.length);
}
return words;
}
@Override
public void write(int address, int[] values) throws IOException {
modbus.writeMultipleRegisters(UNIT_ID, address, values);
}
}
@@ -0,0 +1,132 @@
package home.gateway.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import home.gateway.application.BatteryCommands;
import home.gateway.domain.BatteryResult;
import home.gateway.domain.ScheduleChange;
import home.gateway.domain.SlotChange;
import java.io.IOException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/**
* The Deye settings the app may change (ADR-013), with the same token and throttle as {@code /commands}.
* {@code GET /battery/reserve} answers {pct, floorPct}; {@code POST /battery/reserve} (body: pct) and
* {@code POST /battery/schedule} (body: optional enabled, and slots with slot and optional start, soc,
* gridCharge) answer 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon
* after the last write (S4), 502 failed (read back differs; the body says what was read, nothing is retried).
*/
class BatteryEndpoint implements HttpHandler {
private static final String RESERVE_PATH = "/battery/reserve";
private static final String SCHEDULE_PATH = "/battery/schedule";
private final BatteryCommands battery;
private final TokenGuard guard;
BatteryEndpoint(BatteryCommands battery, String token, Clock clock) {
this.battery = battery;
this.guard = new TokenGuard(token, clock);
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try (exchange) {
var method = exchange.getRequestMethod();
var path = exchange.getRequestURI().getPath();
if (!path.equals(RESERVE_PATH) && !path.equals(SCHEDULE_PATH)) {
respond(exchange, 404, new GatewayHttpServer.Error("not found"));
} else if (guard.admit(exchange)) {
dispatch(exchange, method, path);
}
}
}
private void dispatch(HttpExchange exchange, String method, String path) throws IOException {
if (method.equals("GET") && path.equals(RESERVE_PATH)) {
respond(exchange, 200, battery.reserve());
} else if (method.equals("POST")) {
post(exchange, path);
} else {
exchange.getResponseHeaders().set("Allow", path.equals(RESERVE_PATH) ? "GET, POST" : "POST");
respond(exchange, 405, new GatewayHttpServer.Error("method not allowed"));
}
}
private void post(HttpExchange exchange, String path) throws IOException {
var body = JsonRequests.readJsonBody(exchange);
if (body == null) {
return;
}
var requester = JsonRequests.requester(exchange);
try {
var result = path.equals(RESERVE_PATH)
? battery.changeReserve(reservePct(body), requester)
: battery.changeSchedule(scheduleChange(body, requester));
respond(exchange, httpStatus(result.status()), result);
} catch (IllegalArgumentException e) {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
}
}
private static int reservePct(JsonNode body) {
if (!body.path("pct").isInt()) {
throw new IllegalArgumentException("need an integer \"pct\"");
}
return body.get("pct").intValue();
}
private static ScheduleChange scheduleChange(JsonNode body, String requester) {
var enabled = body.path("enabled");
if (!enabled.isMissingNode() && !enabled.isBoolean()) {
throw new IllegalArgumentException("\"enabled\" must be true or false");
}
if (!body.path("slots").isMissingNode() && !body.path("slots").isArray()) {
throw new IllegalArgumentException("\"slots\" must be a list");
}
var slots = new ArrayList<SlotChange>();
for (JsonNode slot : body.path("slots")) {
slots.add(new SlotChange(integer(slot, "slot", true), integer(slot, "start", false),
integer(slot, "soc", false), bool(slot, "gridCharge")));
}
return new ScheduleChange(enabled.isBoolean() ? enabled.booleanValue() : null, List.copyOf(slots), requester);
}
private static Integer integer(JsonNode slot, String name, boolean required) {
var value = slot.path(name);
if (value.isInt()) {
return value.intValue();
}
if (required || !value.isMissingNode()) {
throw new IllegalArgumentException("\"" + name + "\" must be an integer");
}
return null;
}
private static Boolean bool(JsonNode slot, String name) {
var value = slot.path(name);
if (value.isBoolean()) {
return value.booleanValue();
}
if (!value.isMissingNode()) {
throw new IllegalArgumentException("\"" + name + "\" must be true or false");
}
return null;
}
private static int httpStatus(BatteryResult.Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case STALE -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
}
@@ -14,8 +14,6 @@ import home.gateway.domain.Setpoint;
import home.gateway.domain.VerifiedValue;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
@@ -23,9 +21,7 @@ import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import static home.gateway.adapters.http.GatewayHttpServer.JSON;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/**
@@ -41,12 +37,6 @@ import static home.gateway.adapters.http.GatewayHttpServer.respond;
*/
class CommandsEndpoint implements HttpHandler {
static final int MIN_TOKEN_LENGTH = 16;
private static final String DEFAULT_REQUESTER = "backend";
private static final int MAX_BODY_BYTES = 1024;
private static final Pattern JSON_TYPE = Pattern.compile("application/json\\s*(;.*)?", Pattern.CASE_INSENSITIVE);
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";
@@ -63,18 +53,12 @@ class CommandsEndpoint implements HttpHandler {
private final Commands commands;
private final Snapshot snapshot;
private final byte[] token;
private final FailedLoginThrottle throttle;
private final TokenGuard guard;
CommandsEndpoint(Commands commands, Snapshot snapshot, String token, Clock clock) {
if (token == null || token.length() < MIN_TOKEN_LENGTH) {
throw new IllegalArgumentException(
"command token must be at least " + MIN_TOKEN_LENGTH + " characters");
}
this.guard = new TokenGuard(token, clock);
this.commands = commands;
this.snapshot = snapshot;
this.token = token.getBytes(StandardCharsets.UTF_8);
this.throttle = new FailedLoginThrottle(clock);
}
@Override
@@ -84,15 +68,7 @@ class CommandsEndpoint implements HttpHandler {
var path = exchange.getRequestURI().getPath();
if (!List.of(COMMANDS_PATH, RENEW_PATH, RESTORE_PATH).contains(path)) {
respond(exchange, 404, new GatewayHttpServer.Error("not found"));
} else if (throttle.isBlocked()) {
exchange.getResponseHeaders().set("Retry-After",
String.valueOf(FailedLoginThrottle.COOL_DOWN.toSeconds()));
respond(exchange, 429, new GatewayHttpServer.Error("too many failed attempts"));
} else if (!authorized(exchange)) {
throttle.recordFailure();
exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer");
respond(exchange, 401, new GatewayHttpServer.Error("unauthorized"));
} else {
} else if (guard.admit(exchange)) {
dispatch(exchange, method, path);
}
}
@@ -132,7 +108,7 @@ class CommandsEndpoint implements HttpHandler {
respond(exchange, 405, new GatewayHttpServer.Error("method not allowed"));
return;
}
var body = readJsonBody(exchange);
var body = JsonRequests.readJsonBody(exchange);
if (body == null) {
return;
}
@@ -141,27 +117,18 @@ class CommandsEndpoint implements HttpHandler {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: need a text \"setpoint\""));
return;
}
var result = commands.restoreNow(setpoint.textValue(), requester(exchange));
var result = commands.restoreNow(setpoint.textValue(), JsonRequests.requester(exchange));
respond(exchange, httpStatus(result.outcome()), result);
}
private boolean authorized(HttpExchange exchange) {
var header = exchange.getRequestHeaders().getFirst("Authorization");
if (header == null || !header.startsWith(BEARER)) {
return false;
}
var presented = header.substring(BEARER.length()).getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(presented, token);
}
private void post(HttpExchange exchange) throws IOException {
var body = readJsonBody(exchange);
var body = JsonRequests.readJsonBody(exchange);
if (body == null) {
return;
}
Command command;
try {
command = parse(body, requester(exchange));
command = parse(body, JsonRequests.requester(exchange));
} catch (IllegalArgumentException e) {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
return;
@@ -170,47 +137,6 @@ class CommandsEndpoint implements HttpHandler {
respond(exchange, httpStatus(result.status()), result);
}
/** The parsed body, or null after answering 415, 413 or 400. */
private static JsonNode readJsonBody(HttpExchange exchange) throws IOException {
var contentType = exchange.getRequestHeaders().getFirst("Content-Type");
if (contentType == null || !JSON_TYPE.matcher(contentType).matches()) {
respond(exchange, 415, new GatewayHttpServer.Error("content type must be application/json"));
return null;
}
var body = readLimited(exchange);
if (body == null) {
respond(exchange, 413, new GatewayHttpServer.Error("body larger than " + MAX_BODY_BYTES + " bytes"));
return null;
}
try {
return JSON.readTree(body);
} catch (IOException e) {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
return null;
}
}
private static String requester(HttpExchange exchange) {
var requestedBy = exchange.getRequestHeaders().getFirst("X-Requested-By");
return isPlainName(requestedBy) ? requestedBy : DEFAULT_REQUESTER;
}
/** The body, or null when it is larger than the limit; never reads more than limit + 1 bytes. */
private static byte[] readLimited(HttpExchange exchange) throws IOException {
var declared = exchange.getRequestHeaders().getFirst("Content-Length");
// The JDK server has already rejected a malformed length.
if (declared != null && Long.parseLong(declared.trim()) > MAX_BODY_BYTES) {
return null;
}
var bytes = exchange.getRequestBody().readNBytes(MAX_BODY_BYTES + 1);
return bytes.length > MAX_BODY_BYTES ? null : bytes;
}
/** The name ends up in an Influx tag, so anything unusual is not trusted. */
private static boolean isPlainName(String requestedBy) {
return requestedBy != null && REQUESTER.matcher(requestedBy).matches();
}
private static Command parse(JsonNode body, String requestedBy) {
var setpoint = body.path("setpoint");
var value = body.path("value");
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import home.gateway.application.BatteryCommands;
import home.gateway.application.Commands;
import home.gateway.application.DeviceState;
import home.gateway.application.Snapshot;
@@ -20,7 +21,7 @@ import java.util.concurrent.Executors;
/**
* JSON API: read-only {@code /health} for monitoring and {@code /snapshot} for the app, plus
* {@code /commands} when enabled by {@link #serveCommands}. Only critical
* {@code /commands} and {@code /battery} when enabled by {@link #serveCommands} and {@link #serveBattery}. Only critical
* devices decide the {@code /health} status code, because the deploy script rolls back on a non-200
* and a dead optional device is no reason for that. The body status tells the three cases apart.
*/
@@ -43,7 +44,7 @@ public class GatewayHttpServer implements AutoCloseable {
/** 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;
public static final int MIN_TOKEN_LENGTH = TokenGuard.MIN_TOKEN_LENGTH;
private final Snapshot snapshot;
private final Clock clock;
@@ -68,6 +69,11 @@ public class GatewayHttpServer implements AutoCloseable {
server.createContext("/commands", new CommandsEndpoint(commands, snapshot, token, clock));
}
/** Adds the battery settings endpoints (ADR-013) with their own token check; call before {@link #start()}. */
public void serveBattery(BatteryCommands battery, String token) {
server.createContext("/battery", new BatteryEndpoint(battery, token, clock));
}
public void start() {
server.start();
}
@@ -0,0 +1,63 @@
package home.gateway.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.util.regex.Pattern;
import static home.gateway.adapters.http.GatewayHttpServer.JSON;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/** Reading the body and the requester of a write request. */
final class JsonRequests {
private static final String DEFAULT_REQUESTER = "backend";
private static final int MAX_BODY_BYTES = 1024;
private static final Pattern JSON_TYPE = Pattern.compile("application/json\\s*(;.*)?", Pattern.CASE_INSENSITIVE);
private static final Pattern REQUESTER = Pattern.compile("[\\w.@+-]{1,64}");
private JsonRequests() {
}
/** The parsed body, or null after answering 415, 413 or 400. */
static JsonNode readJsonBody(HttpExchange exchange) throws IOException {
var contentType = exchange.getRequestHeaders().getFirst("Content-Type");
if (contentType == null || !JSON_TYPE.matcher(contentType).matches()) {
respond(exchange, 415, new GatewayHttpServer.Error("content type must be application/json"));
return null;
}
var body = readLimited(exchange);
if (body == null) {
respond(exchange, 413, new GatewayHttpServer.Error("body larger than " + MAX_BODY_BYTES + " bytes"));
return null;
}
try {
return JSON.readTree(body);
} catch (IOException e) {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
return null;
}
}
static String requester(HttpExchange exchange) {
var requestedBy = exchange.getRequestHeaders().getFirst("X-Requested-By");
return isPlainName(requestedBy) ? requestedBy : DEFAULT_REQUESTER;
}
/** The body, or null when it is larger than the limit; never reads more than limit + 1 bytes. */
private static byte[] readLimited(HttpExchange exchange) throws IOException {
var declared = exchange.getRequestHeaders().getFirst("Content-Length");
// The JDK server has already rejected a malformed length.
if (declared != null && Long.parseLong(declared.trim()) > MAX_BODY_BYTES) {
return null;
}
var bytes = exchange.getRequestBody().readNBytes(MAX_BODY_BYTES + 1);
return bytes.length > MAX_BODY_BYTES ? null : bytes;
}
/** The name ends up in an Influx tag, so anything unusual is not trusted. */
private static boolean isPlainName(String requestedBy) {
return requestedBy != null && REQUESTER.matcher(requestedBy).matches();
}
}
@@ -0,0 +1,55 @@
package home.gateway.adapters.http;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/** The shared token of the write endpoints (S6) and the throttle on wrong ones. */
final class TokenGuard {
static final int MIN_TOKEN_LENGTH = 16;
private static final String BEARER = "Bearer ";
private final byte[] token;
private final FailedLoginThrottle throttle;
TokenGuard(String token, Clock clock) {
if (token == null || token.length() < MIN_TOKEN_LENGTH) {
throw new IllegalArgumentException(
"command token must be at least " + MIN_TOKEN_LENGTH + " characters");
}
this.token = token.getBytes(StandardCharsets.UTF_8);
this.throttle = new FailedLoginThrottle(clock);
}
/** True if the request may go on; otherwise it has been answered with 429 or 401. */
boolean admit(HttpExchange exchange) throws IOException {
if (throttle.isBlocked()) {
exchange.getResponseHeaders().set("Retry-After",
String.valueOf(FailedLoginThrottle.COOL_DOWN.toSeconds()));
respond(exchange, 429, new GatewayHttpServer.Error("too many failed attempts"));
return false;
}
if (!authorized(exchange)) {
throttle.recordFailure();
exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer");
respond(exchange, 401, new GatewayHttpServer.Error("unauthorized"));
return false;
}
return true;
}
private boolean authorized(HttpExchange exchange) {
var header = exchange.getRequestHeaders().getFirst("Authorization");
if (header == null || !header.startsWith(BEARER)) {
return false;
}
var presented = header.substring(BEARER.length()).getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(presented, token);
}
}
@@ -2,7 +2,7 @@ package home.gateway.adapters.modbus;
import java.io.IOException;
/** Register reads and single-register writes; values are raw 16-bit words (0–65535), decoding belongs to the device. */
/** Register reads and writes; values are raw 16-bit words (0–65535), decoding belongs to the device. */
public interface ModbusClient {
int[] readHoldingRegisters(int unitId, int address, int count) throws IOException;
@@ -11,4 +11,7 @@ public interface ModbusClient {
/** FC6: succeeds only if the device echoed the address and value. */
void writeSingleRegister(int unitId, int address, int value) throws IOException;
/** FC16, 1 to 123 consecutive registers: succeeds only if the device echoed the address and count. */
void writeMultipleRegisters(int unitId, int address, int[] values) throws IOException;
}
@@ -5,14 +5,16 @@ import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.HexFormat;
/** Modbus functions FC3, FC4 and FC6 encoded and decoded as PDUs over a transport. */
/** Modbus functions FC3, FC4, FC6 and FC16 encoded and decoded as PDUs over a transport. */
public class PduModbusClient implements ModbusClient {
private static final int READ_HOLDING_REGISTERS = 0x03;
private static final int READ_INPUT_REGISTERS = 0x04;
private static final int WRITE_SINGLE_REGISTER = 0x06;
private static final int WRITE_MULTIPLE_REGISTERS = 0x10;
private static final int EXCEPTION_FLAG = 0x80;
private static final int MAX_QUANTITY = 125;
private static final int MAX_WRITE_QUANTITY = 123;
private static final int MAX_ADDRESS = 0xFFFF;
private static final int MAX_VALUE = 0xFFFF;
@@ -48,6 +50,32 @@ public class PduModbusClient implements ModbusClient {
}
}
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) throws IOException {
checkAddress(address);
if (values.length < 1 || values.length > MAX_WRITE_QUANTITY) {
throw new IllegalArgumentException("Register count out of range 1..123: " + values.length);
}
checkAddress(address + values.length - 1);
ByteBuffer request = ByteBuffer.allocate(6 + 2 * values.length)
.put((byte) WRITE_MULTIPLE_REGISTERS).putShort((short) address)
.putShort((short) values.length).put((byte) (2 * values.length));
for (int value : values) {
if (value < 0 || value > MAX_VALUE) {
throw new IllegalArgumentException("Register value out of range 0..65535: " + value);
}
request.putShort((short) value);
}
byte[] response = transport.exchange(unitId, request.array());
throwIfException(WRITE_MULTIPLE_REGISTERS, response);
// The response repeats function, address and quantity: the first five bytes of the request.
byte[] expected = Arrays.copyOf(request.array(), 5);
if (!Arrays.equals(expected, response)) {
throw new IOException("Modbus write echo %s does not match request %s"
.formatted(HexFormat.of().formatHex(response), HexFormat.of().formatHex(expected)));
}
}
private int[] read(int function, int unitId, int address, int count) throws IOException {
checkAddress(address);
if (count < 1 || count > MAX_QUANTITY) {
@@ -0,0 +1,37 @@
package home.gateway.adapters.store;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
/** Replaces a file in one step: a power cut leaves the old content or the new, never half. */
final class AtomicFile {
private AtomicFile() {
}
static void replace(Path file, byte[] content) throws IOException {
var temp = file.resolveSibling(file.getFileName() + ".tmp");
try (var channel = FileChannel.open(temp, StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
channel.write(ByteBuffer.wrap(content));
// Without this a power cut after the move can leave an empty file on the SD card.
channel.force(true);
}
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
syncDirectory(file);
}
/** Makes the rename durable. Not every platform can open a directory, so failure is ignored. */
private static void syncDirectory(Path file) {
try (var directory = FileChannel.open(file.toAbsolutePath().getParent(), StandardOpenOption.READ)) {
directory.force(true);
} catch (IOException | UnsupportedOperationException ignored) {
// The file itself is already durable; only the rename may be lost.
}
}
}
@@ -0,0 +1,43 @@
package home.gateway.adapters.store;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.gateway.application.ReserveStore;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.OptionalInt;
/** The battery reserve as a small JSON file, {@code {"pct": 50}}, replaced atomically (ADR-013). */
public class JsonReserveStore implements ReserveStore {
private final Path file;
private final ObjectMapper json = new ObjectMapper()
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
public JsonReserveStore(Path file) {
this.file = file;
}
/** @throws IOException when the file exists but is not a valid reserve */
@Override
public OptionalInt load() throws IOException {
if (!Files.exists(file)) {
return OptionalInt.empty();
}
return OptionalInt.of(json.readValue(file.toFile(), Stored.class).pct());
}
@Override
public void save(int pct) throws IOException {
AtomicFile.replace(file, json.writeValueAsBytes(new Stored(pct)));
}
private record Stored(@JsonProperty(required = true) int pct) {
}
}
@@ -12,12 +12,9 @@ import home.gateway.application.RestoreStore;
import home.gateway.domain.Restore;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@@ -68,24 +65,7 @@ public class JsonRestoreStore implements RestoreStore {
@Override
public void save(List<Restore> restores) throws IOException {
var temp = file.resolveSibling(file.getFileName() + ".tmp");
try (var channel = FileChannel.open(temp, StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
channel.write(ByteBuffer.wrap(json.writeValueAsBytes(restores.stream().map(Stored::of).toList())));
// Without this a power cut after the move can leave an empty file on the SD card.
channel.force(true);
}
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
syncDirectory();
}
/** Makes the rename durable. Not every platform can open a directory, so failure is ignored. */
private void syncDirectory() {
try (var directory = FileChannel.open(file.toAbsolutePath().getParent(), StandardOpenOption.READ)) {
directory.force(true);
} catch (IOException | UnsupportedOperationException ignored) {
// The file itself is already durable; only the rename may be lost.
}
AtomicFile.replace(file, json.writeValueAsBytes(restores.stream().map(Stored::of).toList()));
}
/**
@@ -0,0 +1,18 @@
package home.gateway.application;
import home.gateway.domain.BatteryResult;
import home.gateway.domain.ScheduleChange;
/** What the HTTP adapter may ask of the battery settings service (ADR-013); the service does every check. */
public interface BatteryCommands {
BatteryResult changeSchedule(ScheduleChange change);
BatteryResult changeReserve(int pct, String requestedBy);
/** The reserve now and the lowest one allowed now. */
Reserve reserve();
record Reserve(int pct, int floorPct) {
}
}
@@ -0,0 +1,11 @@
package home.gateway.application;
import java.io.IOException;
/** The inverter's holding registers, read and written as raw words; one request at a time. */
public interface BatteryRegisters {
int[] read(int address, int count) throws IOException;
void write(int address, int[] values) throws IOException;
}
@@ -0,0 +1,279 @@
package home.gateway.application;
import home.gateway.domain.BatteryResult;
import home.gateway.domain.BatteryResult.Status;
import home.gateway.domain.ProgramRegisters;
import home.gateway.domain.Reading;
import home.gateway.domain.Schedule;
import home.gateway.domain.ScheduleChange;
import home.gateway.domain.Slot;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.LongSupplier;
/**
* The only way the gateway writes to the Deye (ADR-013): the Time of Use table and the backup
* reserve. A table change is checked as a whole against the last reading of the settings, then
* against the registers read right before writing, written in ranges and read back. Nothing is
* retried and nothing is restored: these are persistent settings, as in the Solarman app.
* Calls are serialized, as the logger takes one request at a time.
*/
public class BatterySettings implements BatteryCommands {
private static final Logger LOGGER = LoggerFactory.getLogger(BatterySettings.class);
private static final String AUDIT_DATABASE = "electricity";
private static final String AUDIT_MEASUREMENT = "deye_settings_audit";
/** The device whose last reading is the state we write on top of. */
public static final String SETTINGS_DEVICE = "deye-settings";
private static final String RESERVE_TARGET = "reserve";
static final Duration MAX_READING_AGE = Duration.ofMinutes(30);
static final int DEFAULT_RESERVE_PCT = 50;
// Above the shutdown SOC, where the inverter stops discharging, so the house keeps some energy.
private static final int SHUTDOWN_MARGIN_PCT = 10;
private final BatteryRegisters registers;
private final Snapshot snapshot;
private final ReserveStore store;
private final int configuredFloorPct;
private final ReadingSink audit;
private final Clock clock;
private final LongSupplier nanoTime;
private final Duration minInterval;
private final Pause pause;
private final Runnable pollSoon;
// Not synchronized: blocking I/O inside a monitor would pin a virtual thread's carrier.
private final ReentrantLock lock = new ReentrantLock();
private final Map<Integer, Long> lastWrite = new HashMap<>();
private int storedReservePct = DEFAULT_RESERVE_PCT;
private record Settings(Schedule schedule, int shutdownSocPct) {
}
public BatterySettings(BatteryRegisters registers, Snapshot snapshot, ReserveStore store,
int floorPct, ReadingSink audit, Clock clock, LongSupplier nanoTime, Duration minInterval,
Pause pause, Runnable pollSoon) {
this.registers = registers;
this.snapshot = snapshot;
this.store = store;
this.configuredFloorPct = floorPct;
this.audit = audit;
this.clock = clock;
this.nanoTime = nanoTime;
this.minInterval = minInterval;
this.pause = pause;
this.pollSoon = pollSoon;
try {
storedReservePct = store.load().orElse(DEFAULT_RESERVE_PCT);
} catch (IOException e) {
// A higher reserve is the safe side, so an unreadable file means the default, not a stop.
LOGGER.error("Cannot read the battery reserve, using {} %: {}", DEFAULT_RESERVE_PCT, e.toString());
}
}
@Override
public Reserve reserve() {
int floor = floorPct();
return new Reserve(Math.max(storedReservePct, floor), floor);
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) {
lock.lock();
try {
return changeReserveLocked(pct, requestedBy);
} finally {
lock.unlock();
}
}
@Override
public BatteryResult changeSchedule(ScheduleChange change) {
lock.lock();
try {
return changeScheduleLocked(change);
} finally {
lock.unlock();
}
}
private BatteryResult changeReserveLocked(int pct, String requestedBy) {
var current = reserve();
if (pct < current.floorPct() || pct > Schedule.MAX_SOC) {
return BatteryResult.of(Status.REJECTED,
"the reserve must be " + current.floorPct() + " to " + Schedule.MAX_SOC + " %");
}
if (pct > current.pct()) {
// Raising it must not break an enabled schedule; lowering never can.
var settings = freshSettings();
if (settings == null) {
return BatteryResult.of(Status.STALE, staleMessage());
}
if (settings.schedule().enabled() && lowestSoc(settings.schedule()) < pct) {
return BatteryResult.of(Status.REJECTED, "the schedule is enabled and a slot SOC is below "
+ pct + " %: raise the slot SOCs first or disable the schedule");
}
}
try {
store.save(pct);
} catch (IOException e) {
auditChange(RESERVE_TARGET, requestedBy, "FAILED", current.pct(), pct);
return BatteryResult.of(Status.FAILED, "cannot save the reserve: " + e.getMessage());
}
storedReservePct = pct;
auditChange(RESERVE_TARGET, requestedBy, "APPLIED", current.pct(), pct);
return BatteryResult.of(Status.APPLIED, "applied");
}
private BatteryResult changeScheduleLocked(ScheduleChange change) {
var settings = freshSettings();
if (settings == null) {
return BatteryResult.of(Status.STALE, staleMessage());
}
var current = settings.schedule();
Schedule next;
try {
next = current.with(change);
} catch (IllegalArgumentException e) {
return rejected(change, e.getMessage());
}
var problem = next.problemReplacing(current, reserve().pct());
if (problem.isPresent()) {
return rejected(change, problem.get());
}
if (next.equals(current)) {
return rejected(change, "nothing to change");
}
int[] live;
try {
live = registers.read(ProgramRegisters.FIRST, ProgramRegisters.COUNT);
} catch (IOException e) {
return BatteryResult.of(Status.FAILED, "cannot read the settings before writing: " + e.getMessage());
}
if (!ProgramRegisters.decode(live).equals(current)) {
return BatteryResult.of(Status.STALE,
"the inverter's settings changed since they were read (the Solarman app?): reload and retry");
}
var changes = ProgramRegisters.changes(live, next);
var tooSoon = changes.keySet().stream().filter(this::tooSoon).findFirst();
if (tooSoon.isPresent()) {
return BatteryResult.of(Status.TOO_SOON, "too soon after the last write to register " + tooSoon.get());
}
return write(change, live, changes);
}
private BatteryResult write(ScheduleChange change, int[] live, Map<Integer, Integer> changes) {
String problem = null;
changes.keySet().forEach(address -> lastWrite.put(address, nanoTime.getAsLong()));
try {
for (var range : ProgramRegisters.ranges(changes)) {
registers.write(range.address(), range.values());
}
pause.await();
} catch (IOException e) {
problem = "write failed: " + e.getMessage();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
problem = "interrupted";
}
var read = readBack(changes);
var failed = new ArrayList<Integer>();
for (var entry : changes.entrySet()) {
boolean took = entry.getValue().equals(read.get(entry.getKey()));
if (!took) {
failed.add(entry.getKey());
}
auditChange(String.valueOf(entry.getKey()), change.requestedBy(), took ? "APPLIED" : "FAILED",
live[entry.getKey() - ProgramRegisters.FIRST], entry.getValue());
}
pollSoon.run();
if (problem == null && failed.isEmpty()) {
return new BatteryResult(Status.APPLIED, "applied", read);
}
return new BatteryResult(Status.FAILED, (problem != null ? problem + "; " : "")
+ "registers not as written: " + failed + "; not retried", read);
}
/** What the inverter holds now at the written registers; empty if it cannot be read. */
private Map<Integer, Integer> readBack(Map<Integer, Integer> changes) {
try {
int[] words = registers.read(ProgramRegisters.FIRST, ProgramRegisters.COUNT);
var read = new LinkedHashMap<Integer, Integer>();
changes.keySet().forEach(address -> read.put(address, words[address - ProgramRegisters.FIRST]));
return read;
} catch (IOException e) {
LOGGER.error("Cannot read back the Deye settings: {}", e.toString());
return Map.of();
}
}
private BatteryResult rejected(ScheduleChange change, String message) {
LOGGER.warn("rejected battery change from {}: {}", change.requestedBy(), message);
return BatteryResult.of(Status.REJECTED, message);
}
private boolean tooSoon(int register) {
var last = lastWrite.get(register);
return last != null && nanoTime.getAsLong() - last < minInterval.toNanos();
}
private int floorPct() {
var settings = latestSettings();
return settings == null ? configuredFloorPct
: Math.max(configuredFloorPct, settings.shutdownSocPct() + SHUTDOWN_MARGIN_PCT);
}
private static int lowestSoc(Schedule schedule) {
return schedule.slots().stream().mapToInt(Slot::soc).min().orElseThrow();
}
private static String staleMessage() {
return "the inverter's settings were not read in the last " + MAX_READING_AGE.toMinutes() + " minutes";
}
/** The last reading of the settings if it is recent enough to write on top of, else null. */
private Settings freshSettings() {
return snapshot.state(SETTINGS_DEVICE)
.filter(state -> state.lastSuccess() != null
&& Duration.between(state.lastSuccess(), clock.instant()).compareTo(MAX_READING_AGE) <= 0)
.map(this::settingsOf).orElse(null);
}
/** The last reading of any age: the shutdown SOC hardly ever changes. */
private Settings latestSettings() {
return snapshot.state(SETTINGS_DEVICE).map(this::settingsOf).orElse(null);
}
private Settings settingsOf(DeviceState state) {
return state.readings().stream()
.filter(reading -> reading.measurement().equals("deye_settings"))
.findFirst().map(BatterySettings::fromReading).orElse(null);
}
private static Settings fromReading(Reading reading) {
var fields = reading.fields();
var slots = new ArrayList<Slot>();
for (int slot = 1; slot <= Schedule.SLOTS; slot++) {
String prefix = "prog" + slot + "_";
slots.add(new Slot(fields.get(prefix + "start_hhmm").intValue(), fields.get(prefix + "soc_pct").intValue(),
fields.get(prefix + "grid_charge") == 1.0));
}
return new Settings(new Schedule(fields.get("tou_enabled") == 1.0, fields.get("tou_days").intValue(), slots),
fields.get("shutdown_soc_pct").intValue());
}
private void auditChange(String target, String requestedBy, String status, int old, int value) {
audit.write(List.of(new Reading(AUDIT_DATABASE, AUDIT_MEASUREMENT,
Map.of("register", target, "requested_by", requestedBy, "status", status),
Map.of("old_value", (double) old, "new_value", (double) value), clock.instant())));
}
}
@@ -0,0 +1,14 @@
package home.gateway.application;
import java.io.IOException;
import java.util.OptionalInt;
/** The backup reserve in percent, kept on the Pi's disk so it survives a restart. */
public interface ReserveStore {
/** Empty when nothing was saved yet. */
OptionalInt load() throws IOException;
/** Replaces the stored value atomically. */
void save(int pct) throws IOException;
}
@@ -7,6 +7,7 @@ import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/** The latest state of every device, safe to read while the pollers write. */
@@ -26,6 +27,10 @@ public class Snapshot {
states.computeIfPresent(name, (key, state) -> state.failed(now, error));
}
public Optional<DeviceState> state(String name) {
return Optional.ofNullable(states.get(name));
}
public List<DeviceState> states() {
return states.values().stream().sorted(Comparator.comparing(DeviceState::name)).toList();
}
@@ -0,0 +1,40 @@
package home.gateway.config;
import java.nio.file.Path;
import java.time.Duration;
import static home.gateway.config.Checks.positive;
import static home.gateway.config.Checks.required;
/**
* The write path to the Deye (ADR-013). Off unless {@code enabled}; it needs the Deye enabled and the
* token file of {@code commands}. A disabled section is not validated.
*
* @param reserveFile where the backup reserve is kept, next to the restore file
* @param reserveFloorPct default 30: the reserve never goes below it, nor below the shutdown SOC + 10
* @param minIntervalSeconds default 60: per register, S4
* @param readBackDelayMillis default 1000: the inverter needs about that long to take a value
*/
public record DeyeCommandsConfig(boolean enabled, Path reserveFile, Integer reserveFloorPct,
Integer minIntervalSeconds, Integer readBackDelayMillis) {
public DeyeCommandsConfig {
if (enabled) {
required(reserveFile, "reserveFile");
reserveFloorPct = positive(reserveFloorPct, 30, "reserveFloorPct");
if (reserveFloorPct > 100) {
throw new IllegalArgumentException("reserveFloorPct must be at most 100, got " + reserveFloorPct);
}
minIntervalSeconds = positive(minIntervalSeconds, 60, "minIntervalSeconds");
readBackDelayMillis = positive(readBackDelayMillis, 1000, "readBackDelayMillis");
}
}
static DeyeCommandsConfig disabled() {
return new DeyeCommandsConfig(false, null, null, null, null);
}
public Duration minInterval() {
return Duration.ofSeconds(minIntervalSeconds);
}
}
@@ -18,7 +18,7 @@ import static home.gateway.config.Checks.required;
*/
@JsonIgnoreProperties("_comment")
public record GatewayConfig(Influx influx, Http http, Watchdog watchdog, DevicesConfig devices,
CommandsConfig commands) {
CommandsConfig commands, DeyeCommandsConfig deyeCommands) {
public GatewayConfig {
required(influx, "influx");
@@ -26,6 +26,14 @@ public record GatewayConfig(Influx influx, Http http, Watchdog watchdog, Devices
http = http == null ? new Http(null, null) : http;
watchdog = watchdog == null ? new Watchdog(null, null) : watchdog;
commands = commands == null ? CommandsConfig.disabled() : commands;
deyeCommands = deyeCommands == null ? DeyeCommandsConfig.disabled() : deyeCommands;
if (deyeCommands.enabled()) {
if (!DevicesConfig.Switchable.isOn(devices.deye())) {
throw new IllegalArgumentException("deyeCommands.enabled needs devices.deye.enabled: "
+ "settings are written through the Deye's logger link");
}
required(commands.tokenFile(), "commands.tokenFile (deyeCommands uses the same token)");
}
if (commands.enabled() && !DevicesConfig.Switchable.isOn(devices.heatPump())) {
throw new IllegalArgumentException("commands.enabled needs devices.heatPump.enabled: "
+ "setpoints are written through the heat pump's connection");
@@ -0,0 +1,21 @@
package home.gateway.domain;
import java.util.Map;
/**
* The outcome of one battery change. REJECTED: refused by the rules, nothing written. STALE: the
* settings are unknown or changed since the app showed them, nothing written. TOO_SOON: S4.
* FAILED: a write or its read-back went wrong; {@code read} holds the registers as read afterwards.
*/
public record BatteryResult(Status status, String message, Map<Integer, Integer> read) {
public enum Status { APPLIED, REJECTED, STALE, TOO_SOON, FAILED }
public BatteryResult {
read = read == null ? Map.of() : Map.copyOf(read);
}
public static BatteryResult of(Status status, String message) {
return new BatteryResult(status, message, null);
}
}
@@ -0,0 +1,99 @@
package home.gateway.domain;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* Where the {@link Schedule} lives in the Deye's holding registers (research/energy/README.md):
* 146 holds the Time of Use switch (bit 0) and the days (bits 1 to 7), then starts at 148,
* SOCs at 166 and charge sources at 172. The block FIRST..LAST is read in one request.
*/
public final class ProgramRegisters {
public static final int TIME_OF_USE = 146;
public static final int FIRST = TIME_OF_USE;
public static final int COUNT = 32;
private static final int START = 148;
private static final int SOC = 166;
private static final int SOURCE = 172;
private static final int GRID = 1;
private ProgramRegisters() {
}
/** Reads the block as the poller shows it: a source of 1 or 3 (grid, or grid and generator) is grid charge. */
public static Schedule decode(int[] words) {
var slots = new ArrayList<Slot>();
for (int i = 0; i < Schedule.SLOTS; i++) {
int source = words[SOURCE + i - FIRST];
slots.add(new Slot(words[START + i - FIRST], words[SOC + i - FIRST], (source & GRID) != 0));
}
return new Schedule((words[0] & 1) != 0, (words[0] >> 1) & 0x7F, slots);
}
/**
* The registers that must change so that the block read as {@code current} reads as {@code next},
* by address. The days stay as they are and a slot whose grid charge does not change keeps its
* source, so a generator value we never write is never overwritten; a changed one becomes 0 or 1.
*/
public static Map<Integer, Integer> changes(int[] current, Schedule next) {
var changes = new TreeMap<Integer, Integer>();
var before = decode(current);
if (before.enabled() != next.enabled()) {
changes.put(TIME_OF_USE, (current[0] & ~1) | (next.enabled() ? 1 : 0));
}
for (int i = 0; i < Schedule.SLOTS; i++) {
var slot = next.slots().get(i);
var old = before.slots().get(i);
putIfChanged(changes, START + i, old.start(), slot.start());
putIfChanged(changes, SOC + i, old.soc(), slot.soc());
if (old.gridCharge() != slot.gridCharge()) {
changes.put(SOURCE + i, slot.gridCharge() ? GRID : 0);
}
}
return changes;
}
/**
* Consecutive addresses as one range each, so one FC16 per range; the switch comes last, so a
* schedule is never enabled over a half written table.
*/
public static List<Range> ranges(Map<Integer, Integer> changes) {
var ranges = new ArrayList<Range>();
List<Integer> values = null;
int start = 0;
int previous = -2;
for (var change : new TreeMap<>(changes).entrySet()) {
if (change.getKey() != previous + 1) {
if (values != null) {
ranges.add(Range.of(start, values));
}
values = new ArrayList<>();
start = change.getKey();
}
values.add(change.getValue());
previous = change.getKey();
}
if (values != null) {
ranges.add(Range.of(start, values));
}
ranges.sort((a, b) -> Boolean.compare(a.address() == TIME_OF_USE, b.address() == TIME_OF_USE));
return ranges;
}
private static void putIfChanged(Map<Integer, Integer> changes, int address, int old, int value) {
if (old != value) {
changes.put(address, value);
}
}
/** Registers from {@code address} on, written with one FC16. */
public record Range(int address, int[] values) {
static Range of(int address, List<Integer> values) {
return new Range(address, values.stream().mapToInt(Integer::intValue).toArray());
}
}
}
@@ -0,0 +1,89 @@
package home.gateway.domain;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
/**
* The Deye's Time of Use: the switch, the days it runs (bits Monday to Sunday, kept as the inverter
* holds them) and six slots. The rules below are the allowlist of ADR-013.
*/
public record Schedule(boolean enabled, int days, List<Slot> slots) {
public static final int SLOTS = 6;
public static final int MAX_SOC = 100;
public Schedule {
if (slots.size() != SLOTS) {
throw new IllegalArgumentException("a schedule has " + SLOTS + " slots");
}
slots = List.copyOf(slots);
}
/** @throws IllegalArgumentException for a slot outside 1 to 6 or named twice */
public Schedule with(ScheduleChange change) {
var next = new ArrayList<>(slots);
var seen = new HashSet<Integer>();
for (SlotChange slotChange : change.slots()) {
if (slotChange.slot() < 1 || slotChange.slot() > SLOTS || !seen.add(slotChange.slot())) {
throw new IllegalArgumentException("slots are numbered 1 to " + SLOTS + ", each once");
}
var slot = next.get(slotChange.slot() - 1);
if (slotChange.start() != null) {
slot = slot.withStart(slotChange.start());
}
if (slotChange.soc() != null) {
slot = slot.withSoc(slotChange.soc());
}
if (slotChange.gridCharge() != null) {
slot = slot.withGridCharge(slotChange.gridCharge());
}
next.set(slotChange.slot() - 1, slot);
}
return new Schedule(change.enabled() != null ? change.enabled() : enabled, days, next);
}
/**
* Why this schedule may not replace {@code previous}, or empty. Only what changed is checked,
* except that an enabled schedule must hold the reserve in every slot and a changed start
* needs strictly increasing starts, so the table is valid as a whole.
*/
public Optional<String> problemReplacing(Schedule previous, int reserve) {
boolean startChanged = false;
for (int i = 0; i < SLOTS; i++) {
var slot = slots.get(i);
var before = previous.slots.get(i);
if (slot.start() != before.start()) {
startChanged = true;
if (!isTime(slot.start())) {
return Optional.of("start of slot " + (i + 1) + " must be HHMM, 0000 to 2359");
}
}
if (slot.soc() != before.soc() && (slot.soc() < reserve || slot.soc() > MAX_SOC)) {
return Optional.of("SOC of slot " + (i + 1) + " must be " + reserve + " to " + MAX_SOC);
}
if (enabled && slot.soc() < reserve) {
return Optional.of("SOC of slot " + (i + 1) + " is below the reserve " + reserve
+ ", which an enabled schedule must hold");
}
}
if (startChanged && !startsIncrease()) {
return Optional.of("slot starts must be strictly increasing from slot 1 to 6");
}
return Optional.empty();
}
private boolean startsIncrease() {
for (int i = 1; i < SLOTS; i++) {
if (slots.get(i).start() <= slots.get(i - 1).start()) {
return false;
}
}
return true;
}
private static boolean isTime(int hhmm) {
return hhmm >= 0 && hhmm / 100 < 24 && hhmm % 100 < 60;
}
}
@@ -0,0 +1,11 @@
package home.gateway.domain;
import java.util.List;
/** A requested change of the Time of Use switch and of single slots; a null switch stays as it is. */
public record ScheduleChange(Boolean enabled, List<SlotChange> slots, String requestedBy) {
public ScheduleChange {
slots = List.copyOf(slots);
}
}
@@ -0,0 +1,17 @@
package home.gateway.domain;
/** One program slot of the Deye: it starts at {@code start} (HHMM) and runs to the next slot's start. */
public record Slot(int start, int soc, boolean gridCharge) {
public Slot withStart(int newStart) {
return new Slot(newStart, soc, gridCharge);
}
public Slot withSoc(int newSoc) {
return new Slot(start, newSoc, gridCharge);
}
public Slot withGridCharge(boolean newGridCharge) {
return new Slot(start, soc, newGridCharge);
}
}
@@ -0,0 +1,5 @@
package home.gateway.domain;
/** What to change in one slot (1 to 6); a null field stays as it is. */
public record SlotChange(int slot, Integer start, Integer soc, Boolean gridCharge) {
}
@@ -21,6 +21,11 @@ class DeyeDeviceTest {
/** Serves raw words by register and records every request. */
private static class FakeInverter implements ModbusClient {
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) {
throw new AssertionError("not expected");
}
@Override
public void writeSingleRegister(int unitId, int address, int value) {
throw new UnsupportedOperationException();
@@ -0,0 +1,170 @@
package home.gateway.adapters.deye;
import home.gateway.adapters.modbus.ModbusClient;
import home.gateway.domain.Reading;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class DeyeSettingsDeviceTest {
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
/** Serves the settings read on 2026-10-24 and records every request. */
private static class FakeInverter implements ModbusClient {
final Map<Integer, Integer> registers = new HashMap<>();
final List<String> requests = new ArrayList<>();
FakeInverter() {
registers.putAll(Map.of(98, 1, 102, 280, 104, 100, 108, 80, 109, 120, 111, 1,
115, 20, 116, 50, 117, 35));
registers.putAll(Map.of(127, 30, 128, 40, 130, 1, 141, 1, 142, 2, 143, 9000, 145, 1,
146, 254, 231, 1000, 233, 3100));
int[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int slot = 0; slot < 6; slot++) {
registers.put(148 + slot, starts[slot]);
registers.put(154 + slot, 10000);
registers.put(160 + slot, 4900);
registers.put(166 + slot, 80);
registers.put(172 + slot, 0);
}
}
@Override
public int[] readHoldingRegisters(int unitId, int address, int count) {
requests.add(unitId + ":" + address + "+" + count);
int[] words = new int[count];
for (int i = 0; i < count; i++) {
words[i] = registers.getOrDefault(address + i, 0);
}
return words;
}
@Override
public int[] readInputRegisters(int unitId, int address, int count) {
throw new AssertionError("the Deye is read with FC3 only");
}
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) {
throw new AssertionError("not expected");
}
@Override
public void writeSingleRegister(int unitId, int address, int value) {
throw new AssertionError("the settings are never written");
}
}
private final FakeInverter inverter = new FakeInverter();
private final DeyeSettingsDevice device = new DeyeSettingsDevice(inverter);
@Test
void namesItselfAndPollsEveryTenMinutes() {
assertEquals("deye-settings", device.name());
assertEquals(Duration.ofMinutes(10), device.interval());
}
@Test
void labelsTheReadingAsANewMeasurement() throws IOException {
Reading reading = onlyReading();
assertEquals("electricity", reading.database());
assertEquals("deye_settings", reading.measurement());
assertEquals(Map.of("location", "dom-szczepancowa"), reading.tags());
assertEquals(NOW, reading.time());
}
@Test
void decodesTheBatteryLimitsAndModes() throws IOException {
Map<String, Double> fields = onlyReading().fields();
assertEquals(1.0, fields.get("battery_type"));
assertEquals(280.0, fields.get("battery_capacity_ah"));
assertEquals(100.0, fields.get("zero_export_power_w"));
assertEquals(80.0, fields.get("max_charge_current_a"));
assertEquals(120.0, fields.get("max_discharge_current_a"));
assertEquals(1.0, fields.get("battery_mode"));
assertEquals(20.0, fields.get("shutdown_soc_pct"));
assertEquals(50.0, fields.get("restart_soc_pct"));
assertEquals(35.0, fields.get("low_soc_pct"));
assertEquals(30.0, fields.get("grid_charge_start_soc_pct"));
assertEquals(40.0, fields.get("grid_charge_current_a"));
assertEquals(1.0, fields.get("grid_charge_enabled"));
assertEquals(1.0, fields.get("energy_pattern"));
assertEquals(2.0, fields.get("work_mode"));
assertEquals(9000.0, fields.get("max_sell_power_w"));
assertEquals(1.0, fields.get("export_surplus"));
assertEquals(1000.0, fields.get("grid_max_export_raw"));
assertEquals(3100.0, fields.get("grid_max_import_raw"));
}
@Test
void decodesSixProgramSlots() throws IOException {
Map<String, Double> fields = onlyReading().fields();
assertEquals(100.0, fields.get("prog1_start_hhmm"));
assertEquals(2100.0, fields.get("prog6_start_hhmm"));
assertEquals(10000.0, fields.get("prog3_power_w"));
assertEquals(49.0, fields.get("prog4_voltage_v"), 1e-9);
assertEquals(80.0, fields.get("prog5_soc_pct"));
assertEquals(0.0, fields.get("prog1_grid_charge"));
}
@Test
void splitsTimeOfUseIntoTheSwitchAndTheDays() throws IOException {
Map<String, Double> allDaysOff = onlyReading().fields();
assertEquals(0.0, allDaysOff.get("tou_enabled"));
assertEquals(127.0, allDaysOff.get("tou_days"));
inverter.registers.put(146, 0b0000011);
Map<String, Double> mondayOn = onlyReading().fields();
assertEquals(1.0, mondayOn.get("tou_enabled"));
assertEquals(1.0, mondayOn.get("tou_days"));
}
@Test
void gridChargeIsOnForTheGridAndForGridWithGenerator() throws IOException {
inverter.registers.putAll(Map.of(172, 1, 173, 2, 174, 3));
Map<String, Double> fields = onlyReading().fields();
assertEquals(1.0, fields.get("prog1_grid_charge"));
assertEquals(0.0, fields.get("prog2_grid_charge"));
assertEquals(1.0, fields.get("prog3_grid_charge"));
}
@Test
void readsThreeBlocksInOrder() throws IOException {
onlyReading();
assertEquals(List.of("1:98+20", "1:127+51", "1:231+3"), inverter.requests);
}
@Test
void failsOnAShortAnswer() {
var shortAnswers = new DeyeSettingsDevice(new FakeInverter() {
@Override
public int[] readHoldingRegisters(int unitId, int address, int count) {
return new int[count - 1];
}
});
assertThrows(IOException.class, () -> shortAnswers.read(NOW));
}
private Reading onlyReading() throws IOException {
List<Reading> readings = device.read(NOW);
assertEquals(1, readings.size());
return readings.get(0);
}
}
@@ -0,0 +1,53 @@
package home.gateway.adapters.deye;
import home.gateway.adapters.modbus.MockModbusServer;
import home.gateway.adapters.modbus.PduModbusClient;
import home.gateway.adapters.modbus.TcpTransport;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ModbusBatteryRegistersTest {
private MockModbusServer server;
private TcpTransport transport;
private ModbusBatteryRegisters registers;
@BeforeEach
void start() throws IOException {
server = new MockModbusServer();
for (int address = 146; address < 178; address++) {
server.holding(address, address);
}
transport = new TcpTransport(new TcpTransport.Settings("localhost", server.port(), Duration.ofSeconds(2),
Duration.ofSeconds(2), Duration.ZERO));
registers = new ModbusBatteryRegisters(new PduModbusClient(transport));
}
@AfterEach
void stop() throws IOException {
transport.close();
server.close();
}
@Test
void readsABlockFromUnitOneAndWritesARangeWithOneRequest() throws IOException {
registers.write(166, new int[] {90, 95});
assertEquals(1, server.multipleWrites().size());
assertArrayEquals(new int[] {1, 166, 90, 95}, server.multipleWrites().get(0));
assertArrayEquals(new int[] {90, 95, 168}, registers.read(166, 3));
}
@Test
void failsWhenTheBlockCannotBeRead() {
assertThrows(IOException.class, () -> registers.read(300, 2));
}
}
@@ -146,6 +146,11 @@ class HeatPumpDeviceTest {
}
private static final class FakeController implements ModbusClient {
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) {
throw new AssertionError("not expected");
}
@Override
public void writeSingleRegister(int unitId, int address, int value) {
throw new UnsupportedOperationException();
@@ -87,6 +87,11 @@ class ModbusSetpointRegistersTest {
throw new UnsupportedOperationException();
}
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) {
throw new AssertionError("not expected");
}
@Override
public void writeSingleRegister(int unitId, int address, int value) throws IOException {
if (failure != null) {
@@ -0,0 +1,192 @@
package home.gateway.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.gateway.application.BatteryCommands;
import home.gateway.application.Snapshot;
import home.gateway.domain.BatteryResult;
import home.gateway.domain.BatteryResult.Status;
import home.gateway.domain.ScheduleChange;
import home.gateway.domain.SlotChange;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class BatteryEndpointTest {
private static final String BEARER = "0123456789abcdef-secret";
private static final Path EXAMPLES = Path.of("src/test/resources/commands");
private final ObjectMapper json = new ObjectMapper();
private final HttpClient client = HttpClient.newHttpClient();
private final List<ScheduleChange> schedules = new ArrayList<>();
private final List<String> reserves = new ArrayList<>();
private BatteryResult next = BatteryResult.of(Status.APPLIED, "applied");
private GatewayHttpServer server;
private final BatteryCommands battery = new BatteryCommands() {
@Override
public BatteryResult changeSchedule(ScheduleChange change) {
schedules.add(change);
return next;
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) {
reserves.add(pct + " by " + requestedBy);
return next;
}
@Override
public Reserve reserve() {
return new Reserve(50, 30);
}
};
@BeforeEach
void startServer() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-10-24T10:00:00Z"), ZoneId.of("UTC"));
server = new GatewayHttpServer(new Snapshot(), clock, new InetSocketAddress(0), Set.of());
server.serveBattery(battery, BEARER);
server.start();
}
@AfterEach
void stopServer() {
server.close();
}
private JsonNode example(String file) throws Exception {
return json.readTree(Files.readString(EXAMPLES.resolve(file)));
}
private HttpResponse<String> send(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) {
builder.header("Content-Type", "application/json");
}
for (int i = 0; i < headers.length; i += 2) {
builder.setHeader(headers[i], headers[i + 1]);
}
return client.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
private HttpResponse<String> authorized(String path, String method, String body) throws Exception {
return send(path, method, body, "Authorization", "Bearer " + BEARER, "X-Requested-By", "ala");
}
@Test
void getsTheReserveAndItsFloor() throws Exception {
var response = authorized("/battery/reserve", "GET", null);
assertEquals(200, response.statusCode());
assertEquals(example("battery-reserve.json"), json.readTree(response.body()));
}
@Test
void postsAReserve() throws Exception {
var response = authorized("/battery/reserve", "POST", "{\"pct\":60}");
assertEquals(200, response.statusCode());
assertEquals(List.of("60 by ala"), reserves);
}
@Test
void postsAScheduleChange() throws Exception {
var response = authorized("/battery/schedule", "POST", Files.readString(EXAMPLES.resolve("request-battery-schedule.json")));
assertEquals(200, response.statusCode());
assertEquals(List.of(new ScheduleChange(true, List.of(new SlotChange(2, 930, 90, true),
new SlotChange(3, null, 85, null)), "ala")), schedules);
}
@Test
void answersLikeTheExamples() throws Exception {
next = new BatteryResult(Status.APPLIED, "applied", Map.of(167, 90, 172, 1));
assertEquals(example("response-battery-applied.json"),
json.readTree(authorized("/battery/schedule", "POST", "{\"enabled\":true}").body()));
next = new BatteryResult(Status.FAILED, "registers not as written: [167]; not retried", Map.of(167, 80));
assertEquals(example("response-battery-failed.json"),
json.readTree(authorized("/battery/schedule", "POST", "{\"enabled\":true}").body()));
}
@Test
void statusFollowsTheOutcomeAndTheBodyCarriesWhatWasRead() throws Exception {
var expected = Map.of(Status.APPLIED, 200, Status.REJECTED, 422, Status.STALE, 409,
Status.TOO_SOON, 429, Status.FAILED, 502);
for (var entry : expected.entrySet()) {
next = new BatteryResult(entry.getKey(), "m", Map.of(167, 80));
var response = authorized("/battery/schedule", "POST", "{\"enabled\":false}");
assertEquals(entry.getValue(), response.statusCode(), entry.getKey().name());
JsonNode body = json.readTree(response.body());
assertEquals(entry.getKey().name(), body.get("status").asText());
assertEquals(80, body.get("read").get("167").asInt());
}
}
@Test
void badBodiesAre400AndReachNoService() throws Exception {
for (var body : List.of("{\"pct\":\"60\"}", "{}", "{\"pct\":60.5}")) {
assertEquals(400, authorized("/battery/reserve", "POST", body).statusCode(), body);
}
for (var body : List.of("{\"enabled\":\"yes\"}", "{\"slots\":{}}", "{\"slots\":[{\"soc\":90}]}",
"{\"slots\":[{\"slot\":1,\"soc\":\"9\"}]}", "{\"slots\":[{\"slot\":1,\"gridCharge\":1}]}",
"{\"slots\":[{\"slot\":1,\"start\":\"0930\"}]}")) {
assertEquals(400, authorized("/battery/schedule", "POST", body).statusCode(), body);
}
assertEquals(415, send("/battery/reserve", "POST", null, "Authorization", "Bearer " + BEARER).statusCode());
assertEquals(400, authorized("/battery/reserve", "POST", "not json").statusCode());
assertEquals(List.of(), schedules);
assertEquals(List.of(), reserves);
}
@Test
void needsTheTokenAndThrottlesWrongOnes() throws Exception {
assertEquals(401, send("/battery/reserve", "GET", null).statusCode());
assertEquals(401, send("/battery/schedule", "POST", "{}", "Authorization", "Bearer wrong-token-value-xx")
.statusCode());
for (int i = 0; i < FailedLoginThrottle.MAX_FAILURES; i++) {
send("/battery/reserve", "GET", null);
}
assertEquals(429, authorized("/battery/reserve", "GET", null).statusCode());
assertEquals(List.of(), schedules);
}
@Test
void knowsOnlyItsPathsAndMethods() throws Exception {
assertEquals(404, authorized("/battery/other", "GET", null).statusCode());
assertEquals(405, authorized("/battery/schedule", "GET", null).statusCode());
assertEquals(405, authorized("/battery/reserve", "DELETE", null).statusCode());
}
@Test
void refusesAShortToken() {
var other = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC);
assertThrows(IllegalArgumentException.class, () -> new BatteryEndpoint(battery, "short", other));
}
}
@@ -173,6 +173,11 @@ class HouseMeterDeviceTest {
/** A meter whose input registers hold float32 values, big-endian words; unset ones are 0. */
private static final class FakeMeter implements ModbusClient {
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) {
throw new AssertionError("not expected");
}
@Override
public void writeSingleRegister(int unitId, int address, int value) {
throw new UnsupportedOperationException();
@@ -16,7 +16,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Local Modbus fake answering FC3, FC4 and FC6; unknown addresses get exception code 2. It speaks
* Local Modbus fake answering FC3, FC4, FC6 and FC16; 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 {
@@ -31,6 +31,7 @@ public final class MockModbusServer implements Closeable {
private final AtomicInteger connections = new AtomicInteger();
private final AtomicInteger open = new AtomicInteger();
private final List<int[]> writes = new CopyOnWriteArrayList<>();
private final List<int[]> multipleWrites = new CopyOnWriteArrayList<>();
private volatile Duration responseDelay = Duration.ZERO;
private volatile boolean dropNextRequest;
private volatile boolean wrongTransactionId;
@@ -80,6 +81,11 @@ public final class MockModbusServer implements Closeable {
return writes;
}
/** Every FC16 request received, as {unitId, address, value, value, ...}. */
public List<int[]> multipleWrites() {
return multipleWrites;
}
/** Echoes the next write with the value off by one. */
void echoWrongValueOnNextWrite() {
wrongWriteEcho = true;
@@ -222,6 +228,9 @@ public final class MockModbusServer implements Closeable {
if (function == 6) {
return write(unitId, pdu);
}
if (function == 16) {
return writeMultiple(unitId, pdu);
}
Map<Integer, Integer> registers = switch (function) {
case 3 -> holding;
case 4 -> input;
@@ -260,6 +269,29 @@ public final class MockModbusServer implements Closeable {
return pdu;
}
private byte[] writeMultiple(int unitId, byte[] pdu) {
if (writeException != 0) {
return new byte[] {(byte) 0x90, (byte) writeException};
}
ByteBuffer request = ByteBuffer.wrap(pdu, 1, pdu.length - 1);
int address = request.getShort() & 0xFFFF;
int count = request.getShort() & 0xFFFF;
request.get();
int[] record = new int[2 + count];
record[0] = unitId;
record[1] = address;
for (int i = 0; i < count; i++) {
record[2 + i] = request.getShort() & 0xFFFF;
holding.put(address + i, record[2 + i]);
}
multipleWrites.add(record);
if (wrongWriteEcho) {
wrongWriteEcho = false;
return ByteBuffer.allocate(5).put(pdu, 0, 3).putShort((short) (count + 1)).array();
}
return Arrays.copyOf(pdu, 5);
}
private static byte[] frame(int transactionId, int unitId, byte[] pdu) {
return ByteBuffer.allocate(7 + pdu.length)
.putShort((short) transactionId).putShort((short) 0)
@@ -178,4 +178,50 @@ class PduModbusClientTest {
assertEquals(6, e.function());
assertEquals(3, e.code());
}
@Test
void writesMultipleRegistersAndAcceptsTheEcho() throws IOException {
answering(0x10, 0x00, 0x94, 0x00, 0x02).writeMultipleRegisters(1, 148, new int[] {100, 0xFFFF});
assertEquals(1, lastUnit);
assertArrayEquals(new byte[] {0x10, 0x00, (byte) 0x94, 0, 2, 4, 0, 100, (byte) 0xFF, (byte) 0xFF},
lastRequest);
}
@Test
void rejectsMultipleWriteOutOfRangeBeforeSending() {
PduModbusClient client = answering();
assertThrows(IllegalArgumentException.class, () -> client.writeMultipleRegisters(1, 0, new int[0]));
assertThrows(IllegalArgumentException.class, () -> client.writeMultipleRegisters(1, 0, new int[124]));
assertThrows(IllegalArgumentException.class, () -> client.writeMultipleRegisters(1, 0, new int[] {-1}));
assertThrows(IllegalArgumentException.class, () -> client.writeMultipleRegisters(1, 0, new int[] {65536}));
assertThrows(IllegalArgumentException.class, () -> client.writeMultipleRegisters(1, -1, new int[] {1}));
assertThrows(IllegalArgumentException.class,
() -> client.writeMultipleRegisters(1, 65535, new int[] {1, 2}));
assertNull(lastRequest);
}
@Test
void rejectsMultipleWriteEchoWithOtherAddressQuantityFunctionOrLength() {
assertThrows(IOException.class,
() -> answering(0x10, 0x00, 0x95, 0x00, 0x02).writeMultipleRegisters(1, 148, new int[] {1, 2}));
assertThrows(IOException.class,
() -> answering(0x10, 0x00, 0x94, 0x00, 0x01).writeMultipleRegisters(1, 148, new int[] {1, 2}));
assertThrows(IOException.class,
() -> answering(0x06, 0x00, 0x94, 0x00, 0x02).writeMultipleRegisters(1, 148, new int[] {1, 2}));
assertThrows(IOException.class,
() -> answering(0x10, 0x00, 0x94, 0x00).writeMultipleRegisters(1, 148, new int[] {1, 2}));
assertThrows(IOException.class,
() -> answering(0x10, 0x00, 0x94, 0x00, 0x02, 0x00).writeMultipleRegisters(1, 148, new int[] {1, 2}));
assertThrows(IOException.class, () -> answering().writeMultipleRegisters(1, 148, new int[] {1, 2}));
}
@Test
void reportsMultipleWriteExceptionResponse() {
ModbusException e = assertThrows(ModbusException.class,
() -> answering(0x90, 0x02).writeMultipleRegisters(1, 148, new int[] {1}));
assertEquals(16, e.function());
assertEquals(2, e.code());
}
}
@@ -173,4 +173,35 @@ class TcpTransportTest {
assertThrows(IOException.class, () -> new PduModbusClient(transport).writeSingleRegister(1, 4143, 9));
}
@Test
void writesMultipleRegistersThatTheServerStoresAndReadsBack() throws IOException {
var modbus = new PduModbusClient(transport);
modbus.writeMultipleRegisters(1, 148, new int[] {100, 500, 900});
assertEquals(1, server.multipleWrites().size());
assertArrayEquals(new int[] {1, 148, 100, 500, 900}, server.multipleWrites().get(0));
assertArrayEquals(new int[] {100, 500, 900}, modbus.readHoldingRegisters(1, 148, 3));
}
@Test
void reportsAMultipleWriteTheServerRefuses() {
server.rejectWritesWith(3);
var e = assertThrows(ModbusException.class,
() -> new PduModbusClient(transport).writeMultipleRegisters(1, 148, new int[] {100}));
assertEquals(16, e.function());
assertEquals(3, e.code());
assertTrue(server.multipleWrites().isEmpty());
}
@Test
void rejectsAMultipleWriteThatTheServerEchoesWrongly() {
server.echoWrongValueOnNextWrite();
assertThrows(IOException.class,
() -> new PduModbusClient(transport).writeMultipleRegisters(1, 148, new int[] {100, 200}));
}
}
@@ -17,6 +17,18 @@ class ModbusRtuFrameTest {
assertArrayEquals(READ_10_AT_0, ModbusRtuFrame.build(1, pdu));
}
@Test
void buildsAMultipleWriteRequestAndItsResponse() throws IOException {
byte[] request = {0x10, 0x00, 0x01, 0x00, 0x02, 0x04, 0x00, 0x0A, 0x01, 0x02};
byte[] response = {0x10, 0x00, 0x01, 0x00, 0x02};
assertArrayEquals(new byte[] {0x11, 0x10, 0x00, 0x01, 0x00, 0x02, 0x04, 0x00, 0x0A, 0x01, 0x02,
(byte) 0xC6, (byte) 0xF0}, ModbusRtuFrame.build(0x11, request));
assertArrayEquals(new byte[] {0x11, 0x10, 0x00, 0x01, 0x00, 0x02, 0x12, (byte) 0x98},
ModbusRtuFrame.build(0x11, response));
assertArrayEquals(response, ModbusRtuFrame.parse(ModbusRtuFrame.build(0x11, response), 0x11));
}
@Test
void crcOfAKnownResponse() {
byte[] frame = {0x01, 0x04, 0x02, (byte) 0xFF, (byte) 0xFF};
@@ -5,6 +5,9 @@ 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 home.gateway.adapters.modbus.ModbusException;
import home.gateway.adapters.modbus.PduModbusClient;
import java.io.DataInputStream;
import java.io.IOException;
import java.net.ServerSocket;
@@ -84,6 +87,46 @@ class SolarmanV5TransportTest {
assertArrayEquals(READ_RESPONSE, transport.exchange(UNIT, READ_REQUEST));
}
@Test
void carriesAMultipleRegisterWriteAndItsEcho() throws IOException {
byte[] echo = {0x10, 0x00, (byte) 0xA6, 0x00, 0x02};
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, echo)));
new PduModbusClient(transport).writeMultipleRegisters(UNIT, 166, new int[] {80, 90});
byte[] pdu = {0x10, 0x00, (byte) 0xA6, 0x00, 0x02, 0x04, 0x00, 0x50, 0x00, 0x5A};
byte[] rtu = ModbusRtuFrame.build(UNIT, pdu);
byte[] frame = logger.requests.get(0);
assertArrayEquals(rtu, Arrays.copyOfRange(frame, 26, 26 + rtu.length));
}
@Test
void reportsTheExceptionAnswerToAMultipleRegisterWrite() {
byte[] exception = {(byte) 0x90, 0x02};
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, exception)));
var e = assertThrows(ModbusException.class,
() -> new PduModbusClient(transport).writeMultipleRegisters(UNIT, 166, new int[] {80}));
assertEquals(16, e.function());
assertEquals(2, e.code());
}
@Test
void rejectsAShortOrGarbledAnswerToAMultipleRegisterWrite() throws IOException {
var modbus = new PduModbusClient(transport);
byte[] shortEcho = {0x10, 0x00, (byte) 0xA6};
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, shortEcho)));
assertThrows(IOException.class, () -> modbus.writeMultipleRegisters(UNIT, 166, new int[] {80}));
byte[] otherCount = {0x10, 0x00, (byte) 0xA6, 0x00, 0x03};
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, otherCount)));
assertThrows(IOException.class, () -> modbus.writeMultipleRegisters(UNIT, 166, new int[] {80}));
logger.respondWith(request -> response(request, new byte[] {UNIT, 0x10, 0x00}));
assertThrows(IOException.class, () -> modbus.writeMultipleRegisters(UNIT, 166, new int[] {80}));
}
@Test
void reusesOneConnectionForSuccessiveRequests() throws IOException {
logger.respondWith(request -> response(request, ModbusRtuFrame.build(UNIT, READ_RESPONSE)));
@@ -0,0 +1,50 @@
package home.gateway.adapters.store;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.OptionalInt;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class JsonReserveStoreTest {
@TempDir
Path dir;
@Test
void missingFileIsEmpty() throws IOException {
assertEquals(OptionalInt.empty(), new JsonReserveStore(dir.resolve("reserve.json")).load());
}
@Test
void aSavedReserveComesBackAsASmallJsonFile() throws IOException {
var file = dir.resolve("reserve.json");
var store = new JsonReserveStore(file);
store.save(60);
store.save(55);
assertEquals(OptionalInt.of(55), new JsonReserveStore(file).load());
assertEquals("{\"pct\":55}", Files.readString(file));
try (var files = Files.list(dir)) {
assertEquals(List.of(file), files.toList());
}
}
@Test
void anUnusableFileFailsTheLoad() throws IOException {
var file = dir.resolve("reserve.json");
var store = new JsonReserveStore(file);
for (var content : List.of("", "not json", "{}", "{\"pct\":null}", "{\"pct\":\"50\"}", "[50]",
"{\"pct\":50,\"extra\":1}")) {
Files.writeString(file, content);
assertThrows(IOException.class, store::load, content);
}
}
}
@@ -0,0 +1,566 @@
package home.gateway.application;
import home.gateway.adapters.deye.DeyeSettingsDevice;
import home.gateway.adapters.deye.ModbusBatteryRegisters;
import home.gateway.adapters.modbus.ModbusClient;
import home.gateway.domain.BatteryResult.Status;
import home.gateway.domain.Reading;
import home.gateway.domain.ScheduleChange;
import home.gateway.domain.SlotChange;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.OptionalInt;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The service runs against the real settings device and a fake inverter, so the snapshot it checks is
* exactly what the poller would have stored.
*/
class BatterySettingsTest {
private static final Instant T0 = Instant.parse("2026-10-24T12:00:00Z");
/** The settings read on 2026-10-24 (research/energy/README.md), writable and breakable. */
private static final class FakeInverter implements ModbusClient {
final Map<Integer, Integer> registers = new HashMap<>();
final List<int[]> writes = new ArrayList<>();
int reads;
int failReadNumber;
boolean failWrites;
boolean ignoreWrites;
Runnable beforeRead = () -> { };
FakeInverter() {
registers.putAll(Map.of(98, 1, 102, 280, 104, 100, 108, 80, 109, 120, 111, 1, 115, 20, 116, 50, 117, 35));
registers.putAll(Map.of(127, 30, 128, 40, 130, 1, 141, 1, 142, 2, 143, 9000, 145, 1, 146, 254,
231, 1000, 233, 3100));
int[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int slot = 0; slot < 6; slot++) {
registers.put(148 + slot, starts[slot]);
registers.put(154 + slot, 10000);
registers.put(160 + slot, 4900);
registers.put(166 + slot, 80);
registers.put(172 + slot, 0);
}
}
@Override
public int[] readHoldingRegisters(int unitId, int address, int count) throws IOException {
beforeRead.run();
if (++reads == failReadNumber) {
throw new IOException("read timeout");
}
int[] words = new int[count];
for (int i = 0; i < count; i++) {
words[i] = registers.getOrDefault(address + i, 0);
}
return words;
}
@Override
public int[] readInputRegisters(int unitId, int address, int count) {
throw new AssertionError("FC3 only");
}
@Override
public void writeSingleRegister(int unitId, int address, int value) {
throw new AssertionError("battery settings use FC16");
}
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) throws IOException {
int[] record = new int[1 + values.length];
record[0] = address;
System.arraycopy(values, 0, record, 1, values.length);
writes.add(record);
if (failWrites) {
throw new IOException("write timeout");
}
for (int i = 0; i < values.length && !ignoreWrites; i++) {
registers.put(address + i, values[i]);
}
}
}
private final FakeInverter inverter = new FakeInverter();
private final DeyeSettingsDevice settingsDevice = new DeyeSettingsDevice(inverter);
private final Snapshot snapshot = new Snapshot();
private final List<Reading> audit = new ArrayList<>();
private final MutableClock clock = new MutableClock();
private int storedReserve = -1;
private boolean loadFails;
private boolean saveFails;
private int repolls;
private int floor = 30;
private final ReserveStore store = new ReserveStore() {
@Override
public OptionalInt load() throws IOException {
if (loadFails) {
throw new IOException("corrupt");
}
return storedReserve < 0 ? OptionalInt.empty() : OptionalInt.of(storedReserve);
}
@Override
public void save(int pct) throws IOException {
if (saveFails) {
throw new IOException("disk full");
}
storedReserve = pct;
}
};
private BatterySettings service() {
snapshot.register(DeyeSettingsDevice.NAME, Duration.ofMinutes(10), T0);
poll();
return new BatterySettings(new ModbusBatteryRegisters(inverter), snapshot, store, floor, audit::addAll,
clock, clock::nanos, Duration.ofSeconds(60), () -> { }, () -> repolls++);
}
/** What the poller does every ten minutes; it does not count as one of the service's reads. */
private void poll() {
int before = inverter.reads;
try {
snapshot.recordSuccess(DeyeSettingsDevice.NAME, clock.instant(), settingsDevice.read(clock.instant()));
} catch (IOException e) {
throw new AssertionError(e);
}
inverter.reads = before;
}
private static ScheduleChange change(Boolean enabled, SlotChange... slots) {
return new ScheduleChange(enabled, List.of(slots), "ala");
}
private static SlotChange soc(int slot, int soc) {
return new SlotChange(slot, null, soc, null);
}
private static SlotChange start(int slot, int start) {
return new SlotChange(slot, start, null, null);
}
private static SlotChange grid(int slot, boolean grid) {
return new SlotChange(slot, null, null, grid);
}
private void assertNothingWritten() {
assertTrue(inverter.writes.isEmpty(), "no register may be written");
assertTrue(audit.isEmpty());
}
@Test
void writesTheChangedSocAndReadsItBack() {
var result = service().changeSchedule(change(null, soc(2, 90)));
assertEquals(Status.APPLIED, result.status());
assertEquals(1, inverter.writes.size());
assertArrayEquals(new int[] {167, 90}, inverter.writes.get(0));
assertEquals(Map.of(167, 90), result.read());
assertEquals(1, repolls);
}
@Test
void writesConsecutiveRegistersWithOneRequestAndTheSwitchLast() {
var result = service().changeSchedule(change(true, soc(2, 90), soc(3, 95), start(5, 1800), grid(1, true)));
assertEquals(Status.APPLIED, result.status());
assertEquals(4, inverter.writes.size());
assertArrayEquals(new int[] {152, 1800}, inverter.writes.get(0));
assertArrayEquals(new int[] {167, 90, 95}, inverter.writes.get(1));
assertArrayEquals(new int[] {172, 1}, inverter.writes.get(2));
assertArrayEquals(new int[] {146, 255}, inverter.writes.get(3));
}
@Test
void keepsTheDaysWhenSwitchingTimeOfUse() {
inverter.registers.put(146, 0b1010_1010);
var service = service();
assertEquals(Status.APPLIED, service.changeSchedule(change(false, soc(1, 90))).status());
assertEquals(0b1010_1010, inverter.registers.get(146), "already off: not written");
assertEquals(1, inverter.writes.size());
clock.advance(Duration.ofMinutes(5));
poll();
assertEquals(Status.APPLIED, service.changeSchedule(change(true)).status());
assertEquals(0b1010_1011, inverter.registers.get(146));
}
@Test
void auditsEveryRegisterIntoTheElectricityDatabase() {
service().changeSchedule(change(null, soc(2, 90), soc(3, 95)));
assertEquals(2, audit.size());
var first = audit.get(0);
assertEquals("electricity", first.database());
assertEquals("deye_settings_audit", first.measurement());
assertEquals(Map.of("register", "167", "requested_by", "ala", "status", "APPLIED"), first.tags());
assertEquals(Map.of("old_value", 80.0, "new_value", 90.0), first.fields());
assertEquals("168", audit.get(1).tags().get("register"));
}
@Test
void refusesToEnableWhileASlotSocIsBelowTheReserve() {
inverter.registers.put(168, 45);
var service = service();
var result = service.changeSchedule(change(true));
assertEquals(Status.REJECTED, result.status());
assertTrue(result.message().contains("below the reserve 50"));
assertNothingWritten();
assertEquals(Status.APPLIED, service.changeSchedule(change(true, soc(3, 50))).status());
}
@Test
void keepsAnEnabledScheduleAboveTheReserve() {
inverter.registers.put(146, 255);
inverter.registers.put(168, 45);
var result = service().changeSchedule(change(null, start(1, 130)));
assertEquals(Status.REJECTED, result.status());
assertNothingWritten();
}
@Test
void socMustBeFromTheCurrentReserveTo100() {
var service = service();
assertEquals(Status.REJECTED, service.changeSchedule(change(null, soc(1, 49))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, soc(1, 101))).status());
assertNothingWritten();
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(1, 100))).status());
poll();
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(2, 50))).status());
}
@Test
void theSocMinimumFollowsTheReserve() {
var service = service();
assertEquals(Status.APPLIED, service.changeReserve(40, "ala").status());
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(1, 40))).status());
}
@Test
void startsAreValidTimesAndStrictlyIncreasing() {
var service = service();
assertEquals(Status.REJECTED, service.changeSchedule(change(null, start(1, 160))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, start(1, 2400))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, start(1, -100))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, start(2, 100))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, start(6, 1700))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, start(3, 400))).status());
assertNothingWritten();
assertEquals(Status.APPLIED, service.changeSchedule(change(null, start(6, 2359))).status());
}
@Test
void validatesTheWholeTableBeforeWritingAnything() {
var result = service().changeSchedule(change(null, soc(1, 90), soc(2, 95), start(4, 400)));
assertEquals(Status.REJECTED, result.status());
assertNothingWritten();
}
@Test
void rejectsUnknownOrRepeatedSlotsAndEmptyChanges() {
var service = service();
assertEquals(Status.REJECTED, service.changeSchedule(change(null, soc(0, 90))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, soc(7, 90))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, soc(2, 90), grid(2, true))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null)).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, soc(2, 80))).status());
assertNothingWritten();
}
@Test
void gridChargeIsWrittenAsZeroOrOneAndAGeneratorValueIsLeftAlone() {
inverter.registers.put(172, 3);
inverter.registers.put(173, 2);
var service = service();
assertEquals(Status.REJECTED, service.changeSchedule(change(null, grid(1, true))).status(), "no change");
assertEquals(Status.APPLIED, service.changeSchedule(change(null, grid(1, false), grid(2, true),
soc(6, 90))).status());
assertEquals(0, inverter.registers.get(172));
assertEquals(1, inverter.registers.get(173));
assertEquals(0, inverter.registers.get(174));
}
@Test
void refusesWithoutAReadingOfTheSettings() {
snapshot.register(DeyeSettingsDevice.NAME, Duration.ofMinutes(10), T0);
var service = new BatterySettings(new ModbusBatteryRegisters(inverter), snapshot, store, floor,
audit::addAll, clock, clock::nanos, Duration.ofSeconds(60), () -> { }, () -> repolls++);
var result = service.changeSchedule(change(null, soc(1, 90)));
assertEquals(Status.STALE, result.status());
assertNothingWritten();
assertEquals(0, inverter.reads);
assertEquals(Status.STALE, service.changeReserve(60, "ala").status());
}
@Test
void refusesOnAReadingOlderThanThirtyMinutes() {
var service = service();
clock.advance(Duration.ofMinutes(31));
assertEquals(Status.STALE, service.changeSchedule(change(null, soc(1, 90))).status());
assertNothingWritten();
poll();
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(1, 90))).status());
}
@Test
void refusesWhenTheInverterChangedSinceTheReading() {
var service = service();
inverter.registers.put(167, 70);
var result = service.changeSchedule(change(null, soc(1, 90)));
assertEquals(Status.STALE, result.status());
assertTrue(result.message().contains("changed"));
assertNothingWritten();
assertEquals(0, repolls);
}
@Test
void refusesWhenTheDaysChangedSinceTheReading() {
var service = service();
inverter.registers.put(146, 0b0011_1110);
assertEquals(Status.STALE, service.changeSchedule(change(null, soc(1, 90))).status());
assertNothingWritten();
}
@Test
void failsWithoutWritingWhenTheRegistersCannotBeReadFirst() {
var service = service();
inverter.failReadNumber = 1;
var result = service.changeSchedule(change(null, soc(1, 90)));
assertEquals(Status.FAILED, result.status());
assertNothingWritten();
}
@Test
void reportsAFailedWriteAuditsItAndDoesNotRetry() {
var service = service();
inverter.failWrites = true;
var result = service.changeSchedule(change(null, soc(2, 90), soc(3, 95)));
assertEquals(Status.FAILED, result.status());
assertTrue(result.message().contains("write timeout"));
assertEquals(1, inverter.writes.size());
assertEquals(Map.of(167, 80, 168, 80), result.read());
assertEquals(List.of("FAILED", "FAILED"), audit.stream().map(r -> r.tags().get("status")).toList());
}
@Test
void reportsWhatWasReadWhenTheReadBackDiffers() {
var service = service();
inverter.ignoreWrites = true;
var result = service.changeSchedule(change(null, soc(2, 90)));
assertEquals(Status.FAILED, result.status());
assertEquals(Map.of(167, 80), result.read());
assertEquals(1, inverter.writes.size());
assertEquals("FAILED", audit.get(0).tags().get("status"));
}
@Test
void reportsPartialSuccessPerRegister() {
var service = service();
inverter.beforeRead = () -> {
if (inverter.reads == 1) {
inverter.registers.put(167, 1);
}
};
var result = service.changeSchedule(change(null, soc(2, 90), soc(3, 95)));
assertEquals(Status.FAILED, result.status());
assertEquals(List.of("FAILED", "APPLIED"), audit.stream().map(r -> r.tags().get("status")).toList());
}
@Test
void failsWhenTheReadBackCannotBeRead() {
var service = service();
inverter.failReadNumber = 2;
var result = service.changeSchedule(change(null, soc(2, 90)));
assertEquals(Status.FAILED, result.status());
assertEquals(Map.of(), result.read());
assertEquals("FAILED", audit.get(0).tags().get("status"));
assertEquals(1, inverter.writes.size());
}
@Test
void limitsTheRateOfWritesPerRegister() {
var service = service();
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(2, 90))).status());
clock.advance(Duration.ofSeconds(10));
poll();
assertEquals(Status.TOO_SOON, service.changeSchedule(change(null, soc(2, 95))).status());
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(3, 95))).status());
clock.advance(Duration.ofSeconds(60));
poll();
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(2, 95))).status());
assertEquals(3, audit.size());
}
@Test
void theReserveDefaultsTo50AndTheFloorToTheConfiguredOne() {
var reserve = service().reserve();
assertEquals(50, reserve.pct());
assertEquals(30, reserve.floorPct());
}
@Test
void theFloorIsTheShutdownSocPlusTenWhenThatIsHigher() {
inverter.registers.put(115, 45);
storedReserve = 40;
var reserve = service().reserve();
assertEquals(55, reserve.floorPct());
assertEquals(55, reserve.pct());
}
@Test
void usesTheStoredReserve() {
storedReserve = 65;
assertEquals(65, service().reserve().pct());
}
@Test
void anUnreadableReserveFileMeansTheDefault() {
loadFails = true;
assertEquals(50, service().reserve().pct());
}
@Test
void changesTheReserveStoresItAndAuditsIt() {
var service = service();
var result = service.changeReserve(40, "ala");
assertEquals(Status.APPLIED, result.status());
assertEquals(40, storedReserve);
assertEquals(40, service.reserve().pct());
assertEquals(Map.of("register", "reserve", "requested_by", "ala", "status", "APPLIED"),
audit.get(0).tags());
assertEquals(Map.of("old_value", 50.0, "new_value", 40.0), audit.get(0).fields());
assertTrue(inverter.writes.isEmpty(), "the reserve is the app's, nothing goes to the inverter");
}
@Test
void theReserveStaysWithinTheFloorAnd100() {
var service = service();
assertEquals(Status.REJECTED, service.changeReserve(29, "ala").status());
assertEquals(Status.REJECTED, service.changeReserve(101, "ala").status());
assertEquals(-1, storedReserve);
assertTrue(audit.isEmpty());
}
@Test
void raisingTheReserveAboveASlotSocIsRefusedWhileTheScheduleIsEnabled() {
inverter.registers.put(146, 255);
var service = service();
var result = service.changeReserve(85, "ala");
assertEquals(Status.REJECTED, result.status());
assertTrue(result.message().contains("raise the slot SOCs first"));
assertEquals(-1, storedReserve);
assertEquals(Status.APPLIED, service.changeReserve(80, "ala").status());
}
@Test
void raisingTheReserveIsFineWhileTheScheduleIsOff() {
assertEquals(Status.APPLIED, service().changeReserve(90, "ala").status());
assertEquals(90, storedReserve);
}
@Test
void raisingTheReserveNeedsAFreshReadingButLoweringDoesNot() {
var service = service();
clock.advance(Duration.ofMinutes(45));
assertEquals(Status.STALE, service.changeReserve(60, "ala").status());
assertEquals(Status.APPLIED, service.changeReserve(40, "ala").status());
}
@Test
void aReserveThatCannotBeSavedFailsAndStaysAsItWas() {
var service = service();
saveFails = true;
var result = service.changeReserve(40, "ala");
assertEquals(Status.FAILED, result.status());
assertEquals(50, service.reserve().pct());
assertEquals("FAILED", audit.get(0).tags().get("status"));
}
private static final class MutableClock extends Clock {
private Instant now = T0;
private long nanos;
void advance(Duration by) {
now = now.plus(by);
nanos += by.toNanos();
}
long nanos() {
return nanos;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return now;
}
}
}
@@ -22,6 +22,14 @@ class SnapshotTest {
private final Snapshot snapshot = new Snapshot();
@Test
void findsOneDeviceByName() {
snapshot.register("pump", INTERVAL, T0);
assertEquals("pump", snapshot.state("pump").orElseThrow().name());
assertTrue(snapshot.state("other").isEmpty());
}
@Test
void registeredDeviceHasNoAttemptYet() {
snapshot.register("pump", INTERVAL, T0);
@@ -197,6 +197,44 @@ class GatewayConfigLoaderTest {
assertTrue(error.getMessage().contains("Cannot read"), error.getMessage());
}
private static final String DEYE = "\"deye\": {\"enabled\": true, \"host\": \"localhost\", \"loggerSerial\": 1}";
private static final String TOKEN = "\"commands\": {\"tokenFile\": \"/t/token\"}";
private static String withDeyeCommands(String devices, String commands, String deyeCommands) {
return "{" + INFLUX + ", \"devices\": {" + devices + "}, " + commands + ", \"deyeCommands\": "
+ deyeCommands + "}";
}
@Test
void deyeCommandsAreOffWhenTheSectionIsMissingOrNotEnabled() throws IOException {
assertFalse(load("{" + INFLUX + ", \"devices\": {}}").deyeCommands().enabled());
assertFalse(load(withDeyeCommands("", TOKEN, "{\"reserveFloorPct\": 0}")).deyeCommands().enabled());
}
@Test
void readsEnabledDeyeCommandsWithDefaults() throws IOException {
var deyeCommands = load(withDeyeCommands(DEYE, TOKEN, "{\"enabled\": true, \"reserveFile\": \"/t/reserve.json\"}"))
.deyeCommands();
assertTrue(deyeCommands.enabled());
assertEquals(Path.of("/t/reserve.json"), deyeCommands.reserveFile());
assertEquals(30, deyeCommands.reserveFloorPct());
assertEquals(Duration.ofSeconds(60), deyeCommands.minInterval());
assertEquals(1000, deyeCommands.readBackDelayMillis());
}
@Test
void rejectsDeyeCommandsWithoutTheDeyeTheTokenOrAReserveFile() {
var enabled = "{\"enabled\": true, \"reserveFile\": \"/t/reserve.json\"}";
assertRejected(withDeyeCommands("", TOKEN, enabled), "", "deyeCommands.enabled needs devices.deye.enabled");
assertRejected(withDeyeCommands(DEYE, "\"commands\": {}", enabled), "", "commands.tokenFile");
assertRejected(withDeyeCommands(DEYE, TOKEN, "{\"enabled\": true}"), "deyeCommands", "reserveFile is required");
assertRejected(withDeyeCommands(DEYE, TOKEN, enabled.replace("}", ", \"reserveFloorPct\": 101}")),
"deyeCommands", "reserveFloorPct must be at most 100");
assertRejected(withDeyeCommands(DEYE, TOKEN, enabled.replace("}", ", \"minIntervalSeconds\": 0}")),
"deyeCommands", "minIntervalSeconds must be greater than 0");
}
private static String withCommands(String commands) {
return "{" + INFLUX + ", \"devices\": {\"heatPump\": {\"enabled\": true, " + CONNECTION
+ "}}, \"commands\": " + commands + "}";
@@ -0,0 +1,63 @@
package home.gateway.domain;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ProgramRegistersTest {
/** Registers 146 to 177 as read on 2026-10-24. */
private static int[] block() {
int[] words = new int[ProgramRegisters.COUNT];
words[0] = 254;
int[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int i = 0; i < 6; i++) {
words[2 + i] = starts[i];
words[20 + i] = 80;
}
return words;
}
@Test
void decodesTheSwitchTheDaysAndTheSlots() {
var words = block();
words[0] = 0b1010_1011;
words[26] = 3;
words[27] = 2;
var schedule = ProgramRegisters.decode(words);
assertTrue(schedule.enabled());
assertEquals(0b101_0101, schedule.days());
assertEquals(new Slot(100, 80, true), schedule.slots().get(0));
assertEquals(new Slot(500, 80, false), schedule.slots().get(1));
}
@Test
void changesAreTheDifferingRegistersOnly() {
var current = ProgramRegisters.decode(block());
var next = current.with(new ScheduleChange(true, List.of(new SlotChange(1, 130, null, null),
new SlotChange(6, null, 95, true)), "x"));
assertEquals(Map.of(146, 255, 148, 130, 171, 95, 177, 1), ProgramRegisters.changes(block(), next));
}
@Test
void rangesJoinNeighboursAndPutTheSwitchLast() {
var ranges = ProgramRegisters.ranges(Map.of(146, 255, 148, 130, 149, 600, 166, 90, 168, 91, 169, 92));
assertEquals(List.of(148, 166, 168, 146), ranges.stream().map(ProgramRegisters.Range::address).toList());
assertArrayEquals(new int[] {130, 600}, ranges.get(0).values());
assertArrayEquals(new int[] {91, 92}, ranges.get(2).values());
}
@Test
void noChangesNoRanges() {
assertEquals(List.of(), ProgramRegisters.ranges(Map.of()));
}
}
@@ -0,0 +1 @@
{ "pct": 50, "floorPct": 30 }
@@ -0,0 +1,7 @@
{
"enabled": true,
"slots": [
{ "slot": 2, "start": 930, "soc": 90, "gridCharge": true },
{ "slot": 3, "soc": 85 }
]
}
@@ -0,0 +1 @@
{ "status": "APPLIED", "message": "applied", "read": { "167": 90, "172": 1 } }
@@ -0,0 +1 @@
{ "status": "FAILED", "message": "registers not as written: [167]; not retried", "read": { "167": 80 } }
+35
View File
@@ -49,6 +49,41 @@ Not used: 627–632 and 644–646 (inverter and load voltages and currents, curr
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).
## Deye settings, read 2026-10-24
Holding registers (FC3), decimal addresses; the map is the community's (ha-solarman `deye_p3.yaml`),
the values are what this inverter held. The gateway reads them every 10 min as `electricity.deye_settings`.
| Register | Meaning | Value at check |
|---|---|---|
| 98 | battery type (1 = lithium) | 1 |
| 102 | battery capacity, Ah | 280 |
| 104 | zero export power, W | 100 |
| 108, 109 | max charge, discharge current, A | 80, 120 |
| 111 | battery operation mode (0 voltage, 1 capacity, 2 no battery) | 1 |
| 115, 116, 117 | shutdown, restart, low SOC, % | 20, 50, 35 |
| 127, 128 | grid charging start SOC %, current A | 30, 40 |
| 130 | grid charging switch | 1 |
| 141 | energy pattern (0 battery first, 1 load first) | 1 |
| 142 | work mode (0 selling first, 1 zero export to load, 2 zero export to CT) | 2 |
| 143 | max sell power, W | 9000 |
| 145 | export surplus (solar sell) | 1 |
| 146 | time of use: bit 0 = enabled, bits 1–7 = Mon..Sun | 254 (all days, not enabled) |
| 148–153 | program 1–6 start, HHMM (a slot runs to the next start, slot 6 wraps to 1) | 100, 500, 900, 1300, 1700, 2100 |
| 154–159 | program power, W | 10000 each |
| 160–165 | program voltage, /100 V | 4900 each |
| 166–171 | program SOC, % | 80 each |
| 172–177 | program charge source (0 off, 1 grid, 2 generator, 3 both) | 0 each |
| 231, 233 | grid max export, import (scale uncertain: stored raw, not shown) | 1000, 3100 |
Written by the gateway since ADR-013 (FC16 through the logger, off until enabled): 146 bit 0 only (days kept), 148–153, 166–171,
172–177 as 0 or 1. Verification: the gateway reads 146–177 in one FC3, writes the changed ranges, reads 146–177 again and compares; no
write has run against the real inverter yet, so the first live change is to be watched and noted here (FC16 echo, read-back, whether the
logger answers a write like a read).
Time of use is off, so the battery does not discharge on its own (forum reports agree): it stays full
as a power-cut backup, on purpose. The nominal battery voltage for kWh is 51.2 V (280 Ah = 14.3 kWh).
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.