Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b288c98106 | ||
|
|
e5e13cedb5 | ||
|
|
a594512f33 | ||
|
|
a5ba9e8756 | ||
|
|
619c3ee990 | ||
|
|
8111405326 | ||
|
|
965dfae644 | ||
|
|
2da7246fe4 | ||
|
|
a4dc88cbbc | ||
|
|
a01c507a6e |
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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("—");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 } }
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user