Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b288c98106 | ||
|
|
e5e13cedb5 | ||
|
|
a594512f33 | ||
|
|
a5ba9e8756 | ||
|
|
619c3ee990 | ||
|
|
8111405326 | ||
|
|
965dfae644 | ||
|
|
2da7246fe4 | ||
|
|
a4dc88cbbc | ||
|
|
a01c507a6e | ||
|
|
a8ef638fcd | ||
|
|
8396372943 | ||
|
|
25f76ea084 | ||
|
|
8146296061 | ||
|
|
c21094b4f4 | ||
|
|
3b6d073b1f | ||
|
|
63c552dfd7 | ||
|
|
de19424a7b | ||
|
|
03b4ee1a48 | ||
|
|
355dd76555 | ||
|
|
b6997560d2 | ||
|
|
09042c6338 | ||
|
|
f9fbff4eea | ||
|
|
7e50d1203b | ||
|
|
934330e157 | ||
|
|
2e273f05c2 | ||
|
|
9a48442220 | ||
|
|
b3038aeef7 | ||
|
|
9e997e43f3 | ||
|
|
8ae6cea536 | ||
|
|
604ef4e6d9 | ||
|
|
b0735471b4 | ||
|
|
6f50f5b7b9 | ||
|
|
bad67b9538 | ||
|
|
ce00d0e81b | ||
|
|
0eebba072e | ||
|
|
ea83255b91 | ||
|
|
ba8631b3c2 | ||
|
|
6cae491b1c | ||
|
|
83333a19bf | ||
|
|
0072017966 | ||
|
|
35b3f8e1e0 | ||
|
|
7a17bbbcdb | ||
|
|
f0c974c82a | ||
|
|
d557f98df6 | ||
|
|
9f33f5a94c | ||
|
|
798f98b69d | ||
|
|
e65cb5837f | ||
|
|
0b3197ac55 | ||
|
|
5513c09c51 | ||
|
|
697b68ec39 | ||
|
|
645bbc4b88 | ||
|
|
2d7899ebc8 | ||
|
|
c34a9da0d7 |
No files matched your search
@@ -37,6 +37,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: gateway
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/arm64,linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
|
||||
@@ -44,6 +45,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-backend:${{ gitea.ref_name }},${{ env.IMAGES }}-backend:latest
|
||||
@@ -51,6 +53,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: frontend
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
|
||||
@@ -8,6 +8,9 @@ COPY src src
|
||||
RUN mvn -B -q package -Dmaven.test.skip=true
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
# The release tag, shown in /api/health; the release workflow passes it.
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=$VERSION
|
||||
USER 10001:10001
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
||||
|
||||
+36
-1
@@ -3,6 +3,24 @@
|
||||
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
|
||||
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
|
||||
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
|
||||
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
|
||||
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
|
||||
`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.
|
||||
@@ -16,7 +34,7 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us
|
||||
| `GATEWAY_TIMEOUT` | `5` | seconds |
|
||||
| `INFLUX_TIMEOUT` | `10` | seconds |
|
||||
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
|
||||
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start |
|
||||
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
|
||||
|
||||
```bash
|
||||
mvn -f backend/pom.xml clean package
|
||||
@@ -25,6 +43,23 @@ GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
|
||||
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
|
||||
```
|
||||
|
||||
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
|
||||
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
|
||||
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
|
||||
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
|
||||
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
|
||||
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
|
||||
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
|
||||
|
||||
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
|
||||
pending change of that setpoint now and answers with the gateway's verdict, example
|
||||
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
|
||||
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
|
||||
pending), 503 gateway unreachable.
|
||||
|
||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
|
||||
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
|
||||
rules (ADR-009).
|
||||
|
||||
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
|
||||
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
|
||||
@@ -6,14 +6,19 @@ 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;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.LeaseRenewer;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Wires the adapters and services together; the only place that knows all of them. */
|
||||
final class Backend {
|
||||
@@ -29,13 +34,23 @@ final class Backend {
|
||||
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
|
||||
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
|
||||
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
|
||||
var commands = new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(),
|
||||
settings.gatewayTimeout());
|
||||
var 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 HealthService(gateway, clock),
|
||||
new HistoryService(influx, clock), setpoints);
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
|
||||
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). */
|
||||
static LeaseRenewer leaseRenewer(Settings settings) {
|
||||
return new LeaseRenewer(commands(settings));
|
||||
}
|
||||
|
||||
private static GatewayHttpCommands commands(Settings settings) {
|
||||
return new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(), settings.gatewayTimeout());
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,14 @@ public final class Main {
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
if (settings.gatewayToken() == null) {
|
||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||
} else {
|
||||
var renewer = Backend.leaseRenewer(settings);
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(renewer::close));
|
||||
renewer.start();
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
server.start();
|
||||
System.out.println("home-pump-backend listening on port " + server.port());
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import java.util.stream.Collectors;
|
||||
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
|
||||
public class GatewayHttpClient implements GatewayClient {
|
||||
|
||||
private record Health(List<DeviceHealth> devices) {
|
||||
private record Health(String version, List<DeviceHealth> devices) {
|
||||
}
|
||||
|
||||
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
|
||||
@@ -61,7 +61,7 @@ public class GatewayHttpClient implements GatewayClient {
|
||||
device.lastSuccess(), device.lastError(),
|
||||
measurements.getOrDefault(device.name(), List.of())))
|
||||
.toList();
|
||||
return new GatewaySnapshot(devices);
|
||||
return new GatewaySnapshot(health.version(), devices);
|
||||
}
|
||||
|
||||
private static List<Measurement> measurements(DeviceReadings device) {
|
||||
|
||||
@@ -3,10 +3,16 @@ package home.backend.adapters.gateway;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
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;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -15,23 +21,53 @@ import java.net.http.HttpClient;
|
||||
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);
|
||||
|
||||
/** 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 Request(String setpoint, int value, Long minutes) {
|
||||
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) {
|
||||
}
|
||||
|
||||
private record RestoreRequest(String setpoint) {
|
||||
}
|
||||
|
||||
private record Renewal(int renewed) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
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;
|
||||
@@ -39,6 +75,10 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
/** @param token null when not configured: every call then fails without touching the network */
|
||||
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
|
||||
this.endpoint = baseUri.resolve("/commands");
|
||||
this.renewEndpoint = baseUri.resolve("/commands/renew");
|
||||
this.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();
|
||||
@@ -47,35 +87,78 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
@Override
|
||||
public CommandResult apply(Command command) throws IOException {
|
||||
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
|
||||
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes));
|
||||
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes, command.heldUntil()));
|
||||
HttpRequest.Builder request = request().header("Content-Type", "application/json")
|
||||
.header("X-Requested-By", command.requestedBy())
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
byte[] body = JSON.writeValueAsBytes(new RestoreRequest(setpoint));
|
||||
var request = request(restoreEndpoint).header("Content-Type", "application/json")
|
||||
.header("X-Requested-By", requestedBy).POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return JSON.readValue(send(request, RESTORE_VERDICTS), RestoreResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() throws IOException {
|
||||
var request = request(renewEndpoint).POST(HttpRequest.BodyPublishers.noBody());
|
||||
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request(URI uri) throws IOException {
|
||||
if (token == null) {
|
||||
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
|
||||
}
|
||||
return HttpRequest.newBuilder(endpoint).timeout(timeout).header("Authorization", "Bearer " + token);
|
||||
return HttpRequest.newBuilder(uri).timeout(timeout).header("Authorization", "Bearer " + token);
|
||||
}
|
||||
|
||||
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
|
||||
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,15 +9,23 @@ 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;
|
||||
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;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -37,12 +45,30 @@ 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) {
|
||||
}
|
||||
|
||||
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
|
||||
private record PresetRequest(String until, Map<String, Integer> values) {
|
||||
|
||||
PresetRequest {
|
||||
if (values == null) {
|
||||
values = Map.of();
|
||||
} else if (values.containsValue(null)) {
|
||||
throw new IllegalArgumentException("a value must be a number");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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/";
|
||||
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
|
||||
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
|
||||
private static final String USER_HEADER = "Remote-User";
|
||||
private static final String DEFAULT_USER = "phone";
|
||||
@@ -58,8 +84,10 @@ public class ApiServer {
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/** Binds the port now; 0 picks a free one. */
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, HealthService health,
|
||||
HistoryService history, SetpointService setpoints) throws IOException {
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
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).
|
||||
@@ -70,13 +98,31 @@ 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));
|
||||
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
||||
"GET", () -> getSetpoints(exchange, setpoints),
|
||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||
context(SETPOINTS_PATH, exchange -> {
|
||||
var restore = RESTORE_PATH.matcher(exchange.getRequestURI().getPath());
|
||||
if (restore.matches()) {
|
||||
handle(exchange, Map.of("POST", () -> guarded(exchange,
|
||||
body -> postRestore(exchange, restore.group(1), setpoints))));
|
||||
} else {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
});
|
||||
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, setpoints)))));
|
||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
|
||||
}
|
||||
|
||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||
@@ -175,15 +221,63 @@ public class ApiServer {
|
||||
}
|
||||
}
|
||||
|
||||
private static void postPreset(HttpExchange exchange, SetpointService setpoints) throws IOException {
|
||||
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 {
|
||||
var result = setpoints.restore(setpoint, user(exchange));
|
||||
send(exchange, restoreHttpStatus(result.outcome()), result);
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postPreset(HttpExchange exchange, byte[] body, SetpointService setpoints)
|
||||
throws IOException {
|
||||
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
||||
try {
|
||||
var result = setpoints.applyPreset(id, user(exchange));
|
||||
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
|
||||
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
|
||||
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
|
||||
if (result.isPresent()) {
|
||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||
} else {
|
||||
send(exchange, 404, error("no such preset"));
|
||||
}
|
||||
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
|
||||
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
|
||||
+ "for setpoints of the preset"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
@@ -197,6 +291,26 @@ 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;
|
||||
case NOT_PENDING -> 404;
|
||||
case CONFLICT -> 409;
|
||||
case TOO_SOON -> 429;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static int presetHttpStatus(PresetResult.Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 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,157 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.EnergyDetails;
|
||||
import home.backend.domain.EnergyDetails.Device;
|
||||
import home.backend.domain.EnergyDetails.Group;
|
||||
import home.backend.domain.EnergyDetails.Item;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
/**
|
||||
* F7 details: the Deye, Fronius and house meter fields worth a look, grouped and labelled. The
|
||||
* catalog below is the only place that knows a field's label, unit and place; showing a new field
|
||||
* is one line here.
|
||||
*/
|
||||
public class EnergyDetailsService {
|
||||
|
||||
/** A gateway field shown as {@code label}; {@code scale} turns the stored unit into {@code unit}. */
|
||||
private record Field(String key, String label, String unit, int digits, double scale) {
|
||||
}
|
||||
|
||||
private record Spec(String id, String name, String measurement, List<GroupSpec> groups) {
|
||||
}
|
||||
|
||||
/** {@code measurement} is null for the device's own one. */
|
||||
private record GroupSpec(String title, String measurement, List<Field> fields) {
|
||||
}
|
||||
|
||||
// Each helper returns the fields it stands for, so a group can mix single fields and phase triples.
|
||||
private static Field[] field(String key, String label, String unit, int digits) {
|
||||
return new Field[] {new Field(key, label, unit, digits, 1)};
|
||||
}
|
||||
|
||||
private static Field[] kwhFromWh(String key, String label, int digits) {
|
||||
return new Field[] {new Field(key, label, "kWh", digits, 0.001)};
|
||||
}
|
||||
|
||||
/** L1 to L3 of one quantity; {@code key} holds %d for the phase number. */
|
||||
private static Field[] phases(String key, String label, String unit, int digits) {
|
||||
return IntStream.rangeClosed(1, 3)
|
||||
.mapToObj(n -> new Field(key.formatted(n), label + " L" + n, unit, digits, 1)).toArray(Field[]::new);
|
||||
}
|
||||
|
||||
private static GroupSpec group(String title, Field[]... parts) {
|
||||
return groupOf(null, title, parts);
|
||||
}
|
||||
|
||||
/** A group whose fields come from another measurement of the same device. */
|
||||
private static GroupSpec groupOf(String measurement, String title, Field[]... parts) {
|
||||
return new GroupSpec(title, measurement, Arrays.stream(parts).flatMap(Arrays::stream).toList());
|
||||
}
|
||||
|
||||
private static final List<Spec> CATALOG = List.of(
|
||||
new Spec("deye", "Falownik Deye", "deye", List.of(
|
||||
group("Bateria",
|
||||
field("battery_soc_pct", "Naładowanie", "%", 0),
|
||||
field("battery_power_w", "Moc", "W", 0),
|
||||
field("battery_voltage_v", "Napięcie", "V", 1),
|
||||
field("battery_current_a", "Prąd", "A", 1),
|
||||
field("battery_temperature_c", "Temperatura", "°C", 1)),
|
||||
group("Sieć",
|
||||
field("grid_power_w", "Moc razem", "W", 0),
|
||||
phases("grid_power_l%d_w", "Moc", "W", 0),
|
||||
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
|
||||
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
|
||||
group("Odbiór domu",
|
||||
field("load_power_w", "Moc razem", "W", 0),
|
||||
phases("load_power_l%d_w", "Moc", "W", 0)),
|
||||
group("Panele PV",
|
||||
field("pv1_power_w", "PV1 moc", "W", 0),
|
||||
field("pv1_voltage_v", "PV1 napięcie", "V", 1),
|
||||
field("pv1_current_a", "PV1 prąd", "A", 1),
|
||||
field("pv2_power_w", "PV2 moc", "W", 0),
|
||||
field("pv2_voltage_v", "PV2 napięcie", "V", 1),
|
||||
field("pv2_current_a", "PV2 prąd", "A", 1)),
|
||||
group("Dziś",
|
||||
field("pv_today_kwh", "Produkcja PV", "kWh", 1),
|
||||
field("load_today_kwh", "Zużycie domu", "kWh", 1),
|
||||
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
|
||||
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
|
||||
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
|
||||
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
|
||||
group("Od początku",
|
||||
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
|
||||
field("load_total_kwh", "Zużycie domu", "kWh", 0),
|
||||
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
|
||||
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
|
||||
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
|
||||
field("battery_discharge_total_kwh", "Bateria rozładowana", "kWh", 0)),
|
||||
group("Falownik",
|
||||
field("inverter_power_w", "Moc wyjściowa", "W", 0),
|
||||
field("inverter_temperature_c", "Temperatura", "°C", 1),
|
||||
field("heatsink_temperature_c", "Radiator", "°C", 1)))),
|
||||
new Spec("fronius", "Falownik Fronius", "fronius", List.of(
|
||||
group("Teraz", field("pv_power_w", "Moc PV", "W", 0)),
|
||||
group("Produkcja",
|
||||
kwhFromWh("energy_day_wh", "Dziś", 1),
|
||||
kwhFromWh("energy_year_wh", "Ten rok", 0),
|
||||
kwhFromWh("energy_total_wh", "Od początku", 0)))),
|
||||
new Spec("house-meter", "Licznik domu", "PRAD2", List.of(
|
||||
group("Moc czynna",
|
||||
field("total_power_active", "Razem", "W", 0),
|
||||
phases("p%d_power_active", "Moc", "W", 0)),
|
||||
group("Napięcie", phases("p%d_voltage", "Napięcie", "V", 1)),
|
||||
group("Prąd", phases("p%d_current", "Prąd", "A", 2)),
|
||||
group("Współczynnik mocy", phases("p%d_power_factor", "cos φ", "", 2)),
|
||||
group("Sieć", field("frequency", "Częstotliwość", "Hz", 2)),
|
||||
group("Energia od początku",
|
||||
field("import_energy_active", "Pobrana", "kWh", 0),
|
||||
field("export_energy_active", "Oddana", "kWh", 0)),
|
||||
groupOf("house_meter_phases", "Energia na fazę",
|
||||
phases("l%d_import_energy_active_kwh", "Pobrana", "kWh", 0),
|
||||
phases("l%d_export_energy_active_kwh", "Oddana", "kWh", 0)))));
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public EnergyDetailsService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public EnergyDetails current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
return new EnergyDetails(CATALOG.stream().map(spec -> device(spec, snapshot)).toList());
|
||||
}
|
||||
|
||||
private Device device(Spec spec, GatewaySnapshot snapshot) {
|
||||
Live live = SnapshotFields.live(snapshot, spec.id(), spec.measurement(), clock.instant());
|
||||
List<Group> groups = spec.groups().stream()
|
||||
.map(g -> group(g, g.measurement() == null ? live.fields() : fieldsOf(snapshot, spec.id(), g)))
|
||||
.filter(g -> !g.items().isEmpty()).toList();
|
||||
return new Device(spec.id(), spec.name(), live.source().time(), live.source().stale(), groups);
|
||||
}
|
||||
|
||||
private Map<String, Double> fieldsOf(GatewaySnapshot snapshot, String device, GroupSpec group) {
|
||||
return SnapshotFields.live(snapshot, device, group.measurement(), clock.instant()).fields();
|
||||
}
|
||||
|
||||
private static Group group(GroupSpec spec, Map<String, Double> fields) {
|
||||
List<Item> items = spec.fields().stream().filter(f -> fields.containsKey(f.key()))
|
||||
.map(f -> new Item(f.label(), scaled(fields.get(f.key()), f), f.unit(), f.digits())).toList();
|
||||
return new Group(spec.title(), items);
|
||||
}
|
||||
|
||||
/** Rounded to the shown precision, so a conversion leaves no binary noise such as 4.3999999. */
|
||||
private static double scaled(double value, Field f) {
|
||||
double factor = Math.pow(10, f.digits());
|
||||
return Math.round(value * f.scale() * factor) / factor;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,21 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.EnergyView.Today;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
|
||||
public class EnergyService {
|
||||
|
||||
/** One inverter's fields; empty when it is stale, so nothing old is shown as current. */
|
||||
private record Inverter(Source source, Map<String, Double> fields) {
|
||||
}
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
@@ -31,8 +26,8 @@ public class EnergyService {
|
||||
|
||||
public EnergyView current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
Inverter deye = inverter(snapshot, "deye");
|
||||
Inverter fronius = inverter(snapshot, "fronius");
|
||||
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
|
||||
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
|
||||
Map<String, Double> d = deye.fields();
|
||||
Map<String, Double> f = fronius.fields();
|
||||
|
||||
@@ -46,13 +41,6 @@ public class EnergyService {
|
||||
d.get("load_power_w"), today);
|
||||
}
|
||||
|
||||
private Inverter inverter(GatewaySnapshot snapshot, String name) {
|
||||
Optional<Measurement> reading = SnapshotFields.find(snapshot, name, name);
|
||||
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, name), reading, clock.instant());
|
||||
Source source = new Source(name, reading.map(Measurement::time).orElse(null), stale);
|
||||
return new Inverter(source, stale ? Map.of() : reading.get().fields());
|
||||
}
|
||||
|
||||
private static Instant oldest(List<Source> sources) {
|
||||
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -12,5 +13,11 @@ public interface GatewayCommands {
|
||||
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
|
||||
CommandResult apply(Command command) throws IOException;
|
||||
|
||||
/** Ends the pending change of a setpoint now; a verdict like {@link #apply}. */
|
||||
RestoreResult restore(String setpoint, String requestedBy) throws IOException;
|
||||
|
||||
SetpointState setpoints() throws IOException;
|
||||
|
||||
/** Keeps the gateway's live restores alive; returns how many it extended. */
|
||||
int renewLeases() throws IOException;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HealthView.DeviceHealth;
|
||||
@@ -13,19 +14,21 @@ public class HealthService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
private final String version;
|
||||
|
||||
public HealthService(GatewayClient gateway, Clock clock) {
|
||||
public HealthService(GatewayClient gateway, Clock clock, String version) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public HealthView current() {
|
||||
try {
|
||||
List<DeviceHealth> devices = gateway.snapshot().devices().stream()
|
||||
.map(HealthService::health).toList();
|
||||
return new HealthView(clock.instant(), true, devices);
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
List<DeviceHealth> devices = snapshot.devices().stream().map(HealthService::health).toList();
|
||||
return new HealthView(clock.instant(), true, devices, version, snapshot.version());
|
||||
} catch (IOException e) {
|
||||
return new HealthView(clock.instant(), false, List.of());
|
||||
return new HealthView(clock.instant(), false, List.of(), version, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package home.backend.application;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Tells the gateway now and then that we are still here (ADR-011): while it hears us, our setpoint
|
||||
* changes stay; once we or the tunnel are gone for 15 minutes, it restores them on its own.
|
||||
*/
|
||||
public class LeaseRenewer implements AutoCloseable {
|
||||
|
||||
/** A third of the gateway's lease, so two renewals in a row may fail. */
|
||||
public static final Duration PERIOD = Duration.ofMinutes(5);
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(LeaseRenewer.class);
|
||||
|
||||
private final GatewayCommands gateway;
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("lease-renewal").factory());
|
||||
|
||||
public LeaseRenewer(GatewayCommands gateway) {
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
start(PERIOD);
|
||||
}
|
||||
|
||||
void start(Duration period) {
|
||||
scheduler.scheduleWithFixedDelay(this::renew, 0, period.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/** A failure is logged and retried next cycle; an exception would end the schedule for good. */
|
||||
void renew() {
|
||||
try {
|
||||
int renewed = gateway.renewLeases();
|
||||
LOGGER.debug("renewed {} lease(s)", renewed);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
LOGGER.warn("could not renew the gateway leases, retrying in {}: {}", PERIOD, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.PiSensorView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
|
||||
/** The BME280 on the Pi, read by the gateway every 5 minutes. */
|
||||
public class PiSensorService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public PiSensorService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public PiSensorView current() throws IOException {
|
||||
Live live = SnapshotFields.live(gateway.snapshot(), "bme280", "MEASUREMENT", clock.instant());
|
||||
Map<String, Double> f = live.fields();
|
||||
return new PiSensorView(live.source().time(), live.source().stale(),
|
||||
f.get("temperature"), f.get("humidity"), f.get("pressure"));
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,20 @@ import home.backend.domain.Preset.Change;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.PresetResult.ChangeResult;
|
||||
import home.backend.domain.PresetResult.ChangeStatus;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import home.backend.domain.SetpointsView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
||||
public class SetpointService {
|
||||
@@ -38,48 +43,86 @@ public class SetpointService {
|
||||
return gateway.apply(new Command(setpoint, value, duration(hours), user));
|
||||
}
|
||||
|
||||
/** Puts the value from before the pending change back now, instead of waiting for its end. */
|
||||
public RestoreResult restore(String setpoint, String user) throws IOException {
|
||||
return gateway.restore(setpoint, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
|
||||
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
|
||||
*
|
||||
* @param until the end the user chose; required by a held preset, not allowed for any other
|
||||
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
|
||||
* preset's value
|
||||
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
|
||||
* for a setpoint the preset does not change or outside the gateway's limits
|
||||
*/
|
||||
public Optional<PresetResult> applyPreset(String id, String user) throws IOException {
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
|
||||
throws IOException {
|
||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||
if (preset.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Change> changes = preset.get().changes();
|
||||
if (preset.get().isHeld() != (until != null)) {
|
||||
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
|
||||
: "preset " + id + " has no end time to choose");
|
||||
}
|
||||
List<Change> changes = withValues(preset.get().changes(), values);
|
||||
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, limits)).toList();
|
||||
requireWithinLimits(values, limits);
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
|
||||
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
|
||||
return Optional.of(PresetResult.of(checked));
|
||||
}
|
||||
return Optional.of(PresetResult.of(run(changes, user)));
|
||||
return Optional.of(PresetResult.of(run(changes, until, user)));
|
||||
}
|
||||
|
||||
private List<ChangeResult> run(List<Change> changes, String user) {
|
||||
private static List<Change> withValues(List<Change> changes, Map<String, Integer> values) {
|
||||
Set<String> own = changes.stream().map(Change::setpoint).collect(Collectors.toSet());
|
||||
if (!own.containsAll(values.keySet())) {
|
||||
throw new IllegalArgumentException("values may only be for the setpoints the preset changes: " + own);
|
||||
}
|
||||
return changes.stream().map(c -> new Change(c.setpoint(), values.getOrDefault(c.setpoint(), c.value()),
|
||||
c.hours())).toList();
|
||||
}
|
||||
|
||||
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
|
||||
for (Setpoint limit : limits) {
|
||||
Integer value = values.get(limit.name());
|
||||
if (value != null && !limit.allows(value)) {
|
||||
throw new IllegalArgumentException(limit.name() + " must be " + limit.min() + "–" + limit.max()
|
||||
+ " in steps of " + limit.step());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
|
||||
List<ChangeResult> results = new ArrayList<>();
|
||||
for (Change change : changes) {
|
||||
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
|
||||
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, user));
|
||||
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, until, user));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private ChangeResult attempt(Change change, String user) {
|
||||
private ChangeResult attempt(Change change, Instant until, String user) {
|
||||
try {
|
||||
return ChangeResult.of(apply(change.setpoint(), change.value(), change.hours(), user));
|
||||
var command = until == null ? new Command(change.setpoint(), change.value(), duration(change.hours()), user)
|
||||
: new Command(change.setpoint(), change.value(), null, until, user);
|
||||
return ChangeResult.of(gateway.apply(command));
|
||||
} catch (IOException e) {
|
||||
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
|
||||
}
|
||||
}
|
||||
|
||||
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
|
||||
private static ChangeResult check(Change change, List<Setpoint> limits) {
|
||||
String problem = problem(change, limits);
|
||||
private static ChangeResult check(Change change, boolean held, List<Setpoint> limits) {
|
||||
String problem = problem(change, held, limits);
|
||||
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
|
||||
}
|
||||
|
||||
private static String problem(Change change, List<Setpoint> limits) {
|
||||
private static String problem(Change change, boolean held, List<Setpoint> limits) {
|
||||
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
|
||||
if (known.isEmpty()) {
|
||||
return "preset sets " + change.setpoint() + ", which the gateway does not know";
|
||||
@@ -88,6 +131,11 @@ public class SetpointService {
|
||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
|
||||
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
|
||||
}
|
||||
// The gateway refuses a held raise too; asking first keeps a preset from stopping half way.
|
||||
if (held && known.get().value() != null && change.value() >= known.get().value()) {
|
||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", which is not below the current "
|
||||
+ known.get().value();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
@@ -25,8 +26,28 @@ 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. */
|
||||
record Live(Source source, Map<String, Double> fields) {
|
||||
}
|
||||
|
||||
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, maxAge);
|
||||
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
|
||||
return new Live(source, stale ? Map.of() : reading.get().fields());
|
||||
}
|
||||
|
||||
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Set a setpoint; with a duration the gateway restores the old value afterwards, else it stays. */
|
||||
public record Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||
/**
|
||||
* Set a setpoint. With a duration the gateway restores the old value afterwards; with
|
||||
* {@code heldUntil} it holds a lowering value until then without renewals (ADR-012); else it stays
|
||||
* while the backend renews the lease (ADR-011).
|
||||
*/
|
||||
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
|
||||
|
||||
public Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||
this(setpoint, value, duration, null, requestedBy);
|
||||
}
|
||||
|
||||
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
|
||||
public static final int MAX_HOURS = 72;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Every value the energy devices report, grouped for a details panel (F7). A device without a
|
||||
* fresh reading has {@code stale} true and no groups; a field the device did not report is absent.
|
||||
*/
|
||||
public record EnergyDetails(List<Device> devices) {
|
||||
|
||||
/** One device; {@code id} is the gateway's device name. */
|
||||
public record Device(String id, String name, Instant time, boolean stale, List<Group> groups) {
|
||||
}
|
||||
|
||||
public record Group(String title, List<Item> items) {
|
||||
}
|
||||
|
||||
/** A value in {@code unit}, to be shown with {@code digits} decimals. */
|
||||
public record Item(String label, double value, String unit, int digits) {
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The gateway's latest state of every device, as the backend sees it. */
|
||||
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
|
||||
/** The gateway's latest state of every device, as the backend sees it.
|
||||
* The version is the gateway's release tag, null for a gateway that does not report it yet. */
|
||||
public record GatewaySnapshot(String version, List<DeviceSnapshot> devices) {
|
||||
|
||||
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
|
||||
List<Measurement> measurements) {
|
||||
|
||||
@@ -3,8 +3,10 @@ package home.backend.domain;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Health of the chain phone → backend → gateway → devices (F5). */
|
||||
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
|
||||
/** Health of the chain phone → backend → gateway → devices (F5), with the deployed versions.
|
||||
* The gateway's version is null when it is unreachable or too old to report it. */
|
||||
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices,
|
||||
String backendVersion, String gatewayVersion) {
|
||||
|
||||
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* The BME280 sensor on the Raspberry Pi: °C, % and hPa. When it is stale (device down, no reading
|
||||
* or older than 2 × 300 s) the values are null and {@code stale} is true; {@code time} is null
|
||||
* without any reading.
|
||||
*/
|
||||
public record PiSensorView(Instant time, boolean stale, Double temperature, Double humidity, Double pressure) {
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** A named one-tap bundle of setpoint changes (F3), e.g. boost or away. */
|
||||
public record Preset(String id, String name, List<Change> changes) {
|
||||
/**
|
||||
* A named one-tap bundle of setpoint changes (F3), e.g. boost or away. {@code until} is null, or
|
||||
* {@link #CHOSEN}: the user picks the end on a calendar and the gateway holds the changes until
|
||||
* then without renewals (ADR-012).
|
||||
*/
|
||||
public record Preset(String id, String name, @JsonInclude(JsonInclude.Include.NON_NULL) String until,
|
||||
List<Change> changes) {
|
||||
|
||||
public static final String CHOSEN = "chosen";
|
||||
|
||||
/** {@code hours} null means "until changed back". */
|
||||
public record Change(String setpoint, int value, Double hours) {
|
||||
@@ -18,6 +28,11 @@ public record Preset(String id, String name, List<Change> changes) {
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isHeld() {
|
||||
return CHOSEN.equals(until);
|
||||
}
|
||||
|
||||
public Preset {
|
||||
if (id == null || id.isBlank() || name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("a preset needs an id and a name");
|
||||
@@ -25,5 +40,11 @@ public record Preset(String id, String name, List<Change> changes) {
|
||||
if (changes == null || changes.isEmpty()) {
|
||||
throw new IllegalArgumentException("preset " + id + " has no changes");
|
||||
}
|
||||
if (until != null && !until.equals(CHOSEN)) {
|
||||
throw new IllegalArgumentException("preset " + id + ": until can only be \"" + CHOSEN + "\"");
|
||||
}
|
||||
if (CHOSEN.equals(until) && changes.stream().anyMatch(change -> change.hours() != null)) {
|
||||
throw new IllegalArgumentException("preset " + id + " is held until a chosen time, so no hours");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** A boost the gateway will undo at {@code due}, unless someone changed the register meanwhile. */
|
||||
/**
|
||||
* A change the gateway will undo at {@code due} (null: until changed), unless someone changed the
|
||||
* register meanwhile. It also undoes it when we stop renewing the lease (ADR-011).
|
||||
*/
|
||||
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.domain;
|
||||
|
||||
/** The gateway's answer to "restore now", passed on to the phone unchanged. */
|
||||
public record RestoreResult(Outcome outcome, String message) {
|
||||
|
||||
/**
|
||||
* NOT_PENDING: nothing to restore. CONFLICT: the register was changed meanwhile, so it was left alone.
|
||||
* TOO_SOON: the gateway's rate limit. FAILED: the restore stays pending and is retried.
|
||||
*/
|
||||
public enum Outcome { RESTORED, NOT_PENDING, CONFLICT, TOO_SOON, FAILED }
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
{
|
||||
"id": "away",
|
||||
"name": "Wyjazd",
|
||||
"until": "chosen",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
|
||||
@@ -151,6 +151,11 @@ class BackendTest {
|
||||
assertServesExactly("/api/heat-pump", "heat-pump");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesPiSensorFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/pi-sensor", "pi-sensor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesEnergyFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/energy", "energy");
|
||||
|
||||
@@ -28,8 +28,8 @@ class PresetFileTest {
|
||||
var presets = PresetFile.load(null);
|
||||
|
||||
assertEquals(List.of(
|
||||
new Preset("cwu-now", "Ciepła woda teraz", List.of(new Change("cwu_economy", 45, 2.0))),
|
||||
new Preset("away", "Wyjazd", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Preset("cwu-now", "Ciepła woda teraz", null, List.of(new Change("cwu_economy", 45, 2.0))),
|
||||
new Preset("away", "Wyjazd", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 15, null)))), presets);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class PresetFileTest {
|
||||
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
|
||||
"""));
|
||||
|
||||
assertEquals(List.of(new Preset("bath", "Kąpiel", List.of(new Change("cwu_economy", 48, 1.5)))), presets);
|
||||
assertEquals(List.of(new Preset("bath", "Kąpiel", null, List.of(new Change("cwu_economy", 48, 1.5)))), presets);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -51,6 +51,8 @@ class PresetFileTest {
|
||||
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"tomorrow\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"chosen\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 2}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
|
||||
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
|
||||
Path file = file(json);
|
||||
|
||||
@@ -86,6 +86,20 @@ class GatewayHttpClientTest {
|
||||
assertEquals(645.0, device(devices, "deye").measurements().get(0).fields().get("grid_power_w"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheGatewayVersion() throws Exception {
|
||||
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
|
||||
|
||||
assertEquals("v1.2.1", client.snapshot().version());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOldGatewayWithoutVersionHasNullVersion() throws Exception {
|
||||
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||
|
||||
assertNull(client.snapshot().version());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
|
||||
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||
|
||||
@@ -2,9 +2,14 @@ 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;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -18,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;
|
||||
|
||||
@@ -32,7 +38,7 @@ class GatewayHttpCommandsTest {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Path CONTRACT = Path.of("../gateway/src/test/resources/commands");
|
||||
|
||||
private record Seen(String method, String authorization, String requestedBy, String body) {
|
||||
private record Seen(String method, String path, String authorization, String requestedBy, String body) {
|
||||
}
|
||||
|
||||
private final List<Seen> seen = new ArrayList<>();
|
||||
@@ -59,16 +65,20 @@ class GatewayHttpCommandsTest {
|
||||
}
|
||||
|
||||
private void answerCommands() {
|
||||
server.createContext("/commands", exchange -> {
|
||||
seen.add(new Seen(exchange.getRequestMethod(),
|
||||
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) {
|
||||
@@ -95,6 +105,15 @@ class GatewayHttpCommandsTest {
|
||||
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldChangeSendsTheEndAsUntil() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"), "phone"));
|
||||
|
||||
assertEquals(contractJson("request-held.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayHasNoMinutes() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
@@ -136,6 +155,149 @@ class GatewayHttpCommandsTest {
|
||||
assertEquals(8, state.restores().getFirst().originalValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUntilChangedRestoreHasNoDue() throws Exception {
|
||||
serve(200, "{\"setpoints\":[],\"restores\":[{\"setpoint\":\"cwu_comfort\",\"register\":4144,"
|
||||
+ "\"originalValue\":45,\"appliedValue\":10,\"leaseUntil\":\"2026-10-24T10:15:00Z\"}]}");
|
||||
|
||||
assertNull(commands("s3cret-token").setpoints().restores().getFirst().due());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restorePostsTheSetpointWithTokenAndUser() throws Exception {
|
||||
serve(200, contract("response-restored.json"));
|
||||
|
||||
var result = commands("s3cret-token").restore("cwu_economy", "anna");
|
||||
|
||||
assertEquals(Outcome.RESTORED, result.outcome());
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/commands/restore", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals("anna", seen.getFirst().requestedBy());
|
||||
assertEquals(contractJson("request-restore.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aConflictOnRestoreIsAVerdictNotAnError() throws Exception {
|
||||
serve(409, contract("response-restore-conflict.json"));
|
||||
|
||||
assertEquals(Outcome.CONFLICT, commands("s3cret-token").restore("cwu_economy", "anna").outcome());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRestoreStatusOfTheGatewayIsAVerdict() throws Exception {
|
||||
var expected = java.util.Map.of(404, Outcome.NOT_PENDING, 429, Outcome.TOO_SOON, 502, Outcome.FAILED);
|
||||
for (var entry : expected.entrySet()) {
|
||||
serve(entry.getKey(), "{\"outcome\":\"" + entry.getValue() + "\",\"message\":\"x\"}");
|
||||
|
||||
assertEquals(entry.getValue(), commands("s3cret-token").restore("cwu_economy", "anna").outcome());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreFailsWithoutAVerdictOrToken() {
|
||||
serve(503, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").restore("cwu_economy", "anna"));
|
||||
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).restore("cwu_economy", "anna"));
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewPostsToRenewWithTheTokenAndReturnsHowManyWereExtended() throws Exception {
|
||||
serve(200, "{\"renewed\":2}");
|
||||
|
||||
assertEquals(2, commands("s3cret-token").renewLeases());
|
||||
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/commands/renew", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewFailsOnAnyOtherStatusOrWithoutToken() {
|
||||
serve(503, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").renewLeases());
|
||||
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).renewLeases());
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 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,14 +2,22 @@ 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;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import home.backend.domain.Restore;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
@@ -17,6 +25,7 @@ import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
@@ -57,7 +66,12 @@ class ApiServerTest {
|
||||
private RuntimeException gatewayBug;
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
private Status verdict;
|
||||
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;
|
||||
|
||||
@@ -82,6 +96,7 @@ class ApiServerTest {
|
||||
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
|
||||
};
|
||||
verdict = Status.APPLIED;
|
||||
restoreOutcome = Outcome.RESTORED;
|
||||
answersBeforeDrop = Integer.MAX_VALUE;
|
||||
GatewayCommands commands = new GatewayCommands() {
|
||||
@Override
|
||||
@@ -98,6 +113,15 @@ class ApiServerTest {
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
if (commandsDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
restored.add(setpoint + " by " + requestedBy);
|
||||
return new RestoreResult(restoreOutcome, "restored to 8");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (commandsDown) {
|
||||
@@ -109,11 +133,52 @@ class ApiServerTest {
|
||||
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)),
|
||||
List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
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 HealthService(gateway, clock), new HistoryService(history, clock),
|
||||
new SetpointService(commands, PresetFile.load(null)));
|
||||
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 BatteryService(gateway, batteryCommands, clock));
|
||||
server.start();
|
||||
}
|
||||
|
||||
@@ -133,18 +198,46 @@ 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);
|
||||
return new GatewaySnapshot(List.of(
|
||||
"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))),
|
||||
new DeviceSnapshot("deye", true, energyTime, null,
|
||||
List.of(new Measurement("home", "deye", deye, energyTime))),
|
||||
new DeviceSnapshot("fronius", true, energyTime, null,
|
||||
List.of(new Measurement("home", "fronius",
|
||||
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime)))));
|
||||
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime))),
|
||||
new DeviceSnapshot("house-meter", true, energyTime, null,
|
||||
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
|
||||
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
|
||||
"frequency", 50.01, "import_energy_active", 67514.27), energyTime))),
|
||||
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 {
|
||||
@@ -153,6 +246,10 @@ class ApiServerTest {
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static String awayRequest() throws IOException {
|
||||
return example("preset-away-request").toString();
|
||||
}
|
||||
|
||||
private static JsonNode example(String name) throws IOException {
|
||||
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
|
||||
return JSON.readTree(in);
|
||||
@@ -178,16 +275,21 @@ class ApiServerTest {
|
||||
assertMatchesExample("/api/energy", "energy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyDetailsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/energy/details", "energy-details");
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthMatchesExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot(List.of(snapshot.devices().get(0),
|
||||
snapshot = new GatewaySnapshot("v1.2.1", List.of(snapshot.devices().get(0),
|
||||
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
|
||||
assertMatchesExample("/api/health", "health");
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot(List.of());
|
||||
snapshot = new GatewaySnapshot("v1.2.1", List.of());
|
||||
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
|
||||
}
|
||||
|
||||
@@ -198,10 +300,122 @@ class ApiServerTest {
|
||||
Measurement deye = devices.get(1).measurements().get(0);
|
||||
devices.set(1, new DeviceSnapshot("deye", true, old, null,
|
||||
List.of(new Measurement("home", "deye", deye.fields(), old))));
|
||||
snapshot = new GatewaySnapshot(devices);
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/energy", "energy-deye-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
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");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stalePiSensorMatchesExample() throws Exception {
|
||||
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||
Measurement bme = devices.getLast().measurements().get(0);
|
||||
devices.set(devices.size() - 1, new DeviceSnapshot("bme280", true, old, null,
|
||||
List.of(new Measurement(bme.database(), bme.measurement(), bme.fields(), old))));
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/pi-sensor", "pi-sensor-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
|
||||
@@ -216,7 +430,7 @@ class ApiServerTest {
|
||||
void unreachableGatewayIs503WithJsonError() throws Exception {
|
||||
gatewayDown = true;
|
||||
|
||||
for (String path : List.of("/api/heat-pump", "/api/energy")) {
|
||||
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/energy/details")) {
|
||||
HttpResponse<String> response = get(path);
|
||||
assertEquals(503, response.statusCode());
|
||||
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
|
||||
@@ -301,6 +515,52 @@ class ApiServerTest {
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
|
||||
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
|
||||
assertEquals(List.of("cwu_economy by anna"), restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreStatusFollowsTheGatewaysVerdict() throws Exception {
|
||||
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
|
||||
Outcome.FAILED, 502);
|
||||
for (var entry : expected.entrySet()) {
|
||||
restoreOutcome = entry.getKey();
|
||||
|
||||
assertEquals(entry.getValue(), post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreWithTheGatewayDownIs503() throws Exception {
|
||||
commandsDown = true;
|
||||
|
||||
assertEquals(503, post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreIsGuardedLikeEveryWrite() throws Exception {
|
||||
String path = "/api/setpoints/cwu_economy/restore";
|
||||
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
|
||||
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
|
||||
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
|
||||
assertEquals(405, get(path).statusCode());
|
||||
assertTrue(restored.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherPathsUnderSetpointsAre404() throws Exception {
|
||||
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
|
||||
"/api/setpoints/cwu_economy/restore/x", "/api/setpoints/CWU/restore", "/api/setpoints/a-b/restore")) {
|
||||
assertEquals(404, post(path, "{}").statusCode(), path);
|
||||
}
|
||||
assertTrue(restored.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedIs422AndMatchesExample() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
@@ -343,7 +603,7 @@ class ApiServerTest {
|
||||
|
||||
@Test
|
||||
void presetResultsMatchExample() throws Exception {
|
||||
HttpResponse<String> response = post("/api/presets/away", "");
|
||||
HttpResponse<String> response = post("/api/presets/away", awayRequest());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
JsonNode body = JSON.readTree(response.body());
|
||||
@@ -356,12 +616,66 @@ class ApiServerTest {
|
||||
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
|
||||
answersBeforeDrop = 1;
|
||||
|
||||
HttpResponse<String> response = post("/api/presets/away", "{}");
|
||||
HttpResponse<String> response = post("/api/presets/away", awayRequest());
|
||||
|
||||
assertEquals(502, response.statusCode());
|
||||
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsTheChosenEndHeldInEachChange() throws Exception {
|
||||
post("/api/presets/away", awayRequest());
|
||||
|
||||
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, end, "phone"),
|
||||
new Command("co_comfort", 15, null, end, "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayWithOwnValuesSendsThem() throws Exception {
|
||||
var response = post("/api/presets/away", example("preset-away-values-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
|
||||
assertEquals(List.of(new Command("cwu_comfort", 12, null, end, "phone"),
|
||||
new Command("co_comfort", 16, null, end, "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValuesForOtherSetpointsOrOutsideTheLimitsAre400AndSendNothing() throws Exception {
|
||||
String until = "\"until\":\"2026-11-02T18:00:00Z\"";
|
||||
for (String values : List.of("{\"cwu_economy\":6}", "{\"co_comfort\":4}", "{\"co_comfort\":26}",
|
||||
"{\"co_comfort\":null}", "{\"co_comfort\":15.5}", "{\"co_comfort\":\"15\"}", "[]")) {
|
||||
assertEquals(400, post("/api/presets/away", "{" + until + ",\"values\":" + values + "}").statusCode(),
|
||||
values);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValueThatDoesNotLowerAHeldSetpointIs422() throws Exception {
|
||||
var body = "{\"until\":\"2026-11-02T18:00:00Z\",\"values\":{\"co_comfort\":19}}";
|
||||
|
||||
assertEquals(422, post("/api/presets/away", body).statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badOrMissingEndTimesAre400AndSendNothing() throws Exception {
|
||||
for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}",
|
||||
"{\"until\":\"2026-11-02T18:00:00\"}", "not json", "{\"until\":\"2026-11-02T18:00:00Z\",\"x\":1}")) {
|
||||
assertEquals(400, post("/api/presets/away", body).statusCode(), body);
|
||||
}
|
||||
assertEquals(400, post("/api/presets/cwu-now", awayRequest()).statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetWithoutAnEndStillTakesNoBodyOrAnEmptyObject() throws Exception {
|
||||
assertEquals(200, post("/api/presets/cwu-now", "").statusCode());
|
||||
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIs404() throws Exception {
|
||||
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
||||
@@ -372,7 +686,7 @@ class ApiServerTest {
|
||||
void presetOutsideLimitsIs422() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
|
||||
assertEquals(422, post("/api/presets/away", "").statusCode());
|
||||
assertEquals(422, post("/api/presets/away", awayRequest()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -381,7 +695,7 @@ class ApiServerTest {
|
||||
|
||||
assertEquals(503, get("/api/setpoints").statusCode());
|
||||
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
|
||||
assertEquals(503, post("/api/presets/away", "").statusCode());
|
||||
assertEquals(503, post("/api/presets/away", awayRequest()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class LeaseRenewerTest {
|
||||
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
private final CountDownLatch threeCalls = new CountDownLatch(3);
|
||||
private Exception failure;
|
||||
|
||||
private final GatewayCommands gateway = new GatewayCommands() {
|
||||
@Override
|
||||
public CommandResult apply(Command command) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() throws IOException {
|
||||
calls.incrementAndGet();
|
||||
threeCalls.countDown();
|
||||
if (failure instanceof IOException e) {
|
||||
throw e;
|
||||
}
|
||||
if (failure instanceof RuntimeException e) {
|
||||
throw e;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
@Test
|
||||
void renewsAtOnceAndThenEveryPeriod() throws Exception {
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.start(Duration.ofMillis(10));
|
||||
|
||||
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedRenewalDoesNotStopTheSchedule() throws Exception {
|
||||
failure = new IOException("tunnel down");
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.start(Duration.ofMillis(10));
|
||||
|
||||
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnexpectedErrorDoesNotStopTheScheduleEither() throws Exception {
|
||||
failure = new IllegalStateException("bad answer");
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.start(Duration.ofMillis(10));
|
||||
|
||||
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewDoesNotThrowOutOfTheScheduler() {
|
||||
failure = new IOException("tunnel down");
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.renew();
|
||||
}
|
||||
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
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;
|
||||
import home.backend.domain.EnergyDetails.Item;
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
@@ -9,6 +16,7 @@ import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HeatPumpView;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HistoryView;
|
||||
import home.backend.domain.PiSensorView;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -18,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;
|
||||
|
||||
@@ -40,7 +49,7 @@ class ServicesTest {
|
||||
}
|
||||
|
||||
private static GatewayClient gateway(DeviceSnapshot... devices) {
|
||||
return () -> new GatewaySnapshot(List.of(devices));
|
||||
return () -> new GatewaySnapshot("v1.2.1", List.of(devices));
|
||||
}
|
||||
|
||||
private static GatewayClient unreachable() {
|
||||
@@ -166,6 +175,189 @@ 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();
|
||||
}
|
||||
|
||||
@Test
|
||||
void piSensorReadsTemperatureHumidityAndPressure() throws IOException {
|
||||
PiSensorView view = piSensor(device("bme280", "measurement", "MEASUREMENT",
|
||||
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8)));
|
||||
|
||||
assertEquals(READ, view.time());
|
||||
assertFalse(view.stale());
|
||||
assertEquals(20.17, view.temperature());
|
||||
assertEquals(45.82, view.humidity());
|
||||
assertEquals(985.8, view.pressure());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stalePiSensorShowsNoValues() throws IOException {
|
||||
var old = new Measurement("measurement", "MEASUREMENT", Map.of("temperature", 20.0), NOW.minusSeconds(601));
|
||||
PiSensorView view = piSensor(new DeviceSnapshot("bme280", true, old.time(), null, List.of(old)));
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertNull(view.temperature());
|
||||
assertTrue(piSensor().stale());
|
||||
assertNull(piSensor().time());
|
||||
}
|
||||
|
||||
private static EnergyView energy(DeviceSnapshot... devices) throws IOException {
|
||||
return new EnergyService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
@@ -215,6 +407,80 @@ class ServicesTest {
|
||||
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
|
||||
}
|
||||
|
||||
private static EnergyDetails details(DeviceSnapshot... devices) throws IOException {
|
||||
return new EnergyDetailsService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
|
||||
private static EnergyDetails.Device detailsOf(EnergyDetails details, String id) {
|
||||
return details.devices().stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsShowOnlyReportedFieldsAndDropEmptyGroups() throws IOException {
|
||||
EnergyDetails.Device deye = detailsOf(details(device("deye", "home", "deye",
|
||||
Map.of("battery_soc_pct", 80.0, "grid_power_l2_w", 52.0, "pv1_power_w", 10.0))), "deye");
|
||||
|
||||
assertFalse(deye.stale());
|
||||
assertEquals(READ, deye.time());
|
||||
assertEquals(List.of("Bateria", "Sieć", "Panele PV"), deye.groups().stream().map(Group::title).toList());
|
||||
assertEquals(List.of(new Item("Moc L2", 52.0, "W", 0)), deye.groups().get(1).items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsOfAStaleDeviceHaveNoValues() throws IOException {
|
||||
Instant old = NOW.minusSeconds(3600);
|
||||
var stale = new DeviceSnapshot("deye", true, old, null, List.of(new Measurement("home", "deye", DEYE, old)));
|
||||
|
||||
EnergyDetails.Device deye = detailsOf(details(stale), "deye");
|
||||
|
||||
assertTrue(deye.stale());
|
||||
assertEquals(old, deye.time());
|
||||
assertTrue(deye.groups().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsListEveryDeviceEvenWhenTheGatewayKnowsNone() throws IOException {
|
||||
EnergyDetails all = details();
|
||||
|
||||
assertEquals(List.of("deye", "fronius", "house-meter"), all.devices().stream().map(Device::id).toList());
|
||||
assertTrue(all.devices().stream().allMatch(d -> d.stale() && d.groups().isEmpty()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsConvertFroniusWhToKwhAndRoundThem() throws IOException {
|
||||
EnergyDetails.Device fronius = detailsOf(details(device("fronius", "home", "fronius",
|
||||
Map.of("energy_day_wh", 4399.99, "energy_total_wh", 2.90713E7))), "fronius");
|
||||
|
||||
assertEquals(List.of(new Item("Dziś", 4.4, "kWh", 1), new Item("Od początku", 29071.0, "kWh", 0)),
|
||||
fronius.groups().get(0).items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsReadTheHouseMeterPerPhase() throws IOException {
|
||||
EnergyDetails.Device meter = detailsOf(details(device("house-meter", "electricity", "PRAD2",
|
||||
Map.of("p1_voltage", 235.69, "p3_voltage", 232.2))), "house-meter");
|
||||
|
||||
assertEquals(List.of(new Item("Napięcie L1", 235.7, "V", 1), new Item("Napięcie L3", 232.2, "V", 1)),
|
||||
meter.groups().get(0).items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsTakeThePhaseEnergyFromItsOwnMeasurement() throws IOException {
|
||||
var meter = new DeviceSnapshot("house-meter", true, READ, null, List.of(
|
||||
new Measurement("electricity", "PRAD2", Map.of("frequency", 50.01), READ),
|
||||
new Measurement("electricity", "house_meter_phases", Map.of("l2_import_energy_active_kwh", 7012.4), READ)));
|
||||
|
||||
Group phases = detailsOf(details(meter), "house-meter").groups().getLast();
|
||||
|
||||
assertEquals("Energia na fazę", phases.title());
|
||||
assertEquals(List.of(new Item("Pobrana L2", 7012.0, "kWh", 0)), phases.items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsFailWhenTheGatewayIsUnreachable() {
|
||||
assertThrows(IOException.class, () -> new EnergyDetailsService(unreachable(), CLOCK).current());
|
||||
}
|
||||
|
||||
private static DeviceSnapshot deyeRead(boolean up, Instant time) {
|
||||
return new DeviceSnapshot("deye", up, time, null, List.of(new Measurement("home", "deye", DEYE, time)));
|
||||
}
|
||||
@@ -269,7 +535,7 @@ class ServicesTest {
|
||||
DeviceSnapshot down = new DeviceSnapshot("deye", false, null, "connect timed out", List.of());
|
||||
|
||||
HealthView view = new HealthService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", Map.of()),
|
||||
down), CLOCK).current();
|
||||
down), CLOCK, "v1.3.0").current();
|
||||
|
||||
assertTrue(view.gatewayReachable());
|
||||
assertEquals(NOW, view.time());
|
||||
@@ -279,12 +545,22 @@ class ServicesTest {
|
||||
assertEquals(READ, view.devices().get(0).lastSuccess());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthReportsBothVersions() {
|
||||
HealthView view = new HealthService(gateway(), CLOCK, "v1.3.0").current();
|
||||
|
||||
assertEquals("v1.3.0", view.backendVersion());
|
||||
assertEquals("v1.2.1", view.gatewayVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthWithUnreachableGatewayHasNoDevices() {
|
||||
HealthView view = new HealthService(unreachable(), CLOCK).current();
|
||||
HealthView view = new HealthService(unreachable(), CLOCK, "v1.3.0").current();
|
||||
|
||||
assertFalse(view.gatewayReachable());
|
||||
assertTrue(view.devices().isEmpty());
|
||||
assertEquals("v1.3.0", view.backendVersion());
|
||||
assertNull(view.gatewayVersion());
|
||||
}
|
||||
|
||||
/** Remembers the request and answers with one point. */
|
||||
|
||||
@@ -3,6 +3,8 @@ package home.backend.application;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
@@ -16,8 +18,10 @@ 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.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -25,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SetpointServiceTest {
|
||||
|
||||
private static final Instant RETURN = Instant.parse("2026-11-02T17:00:00Z");
|
||||
private static final SetpointState STATE = new SetpointState(List.of(
|
||||
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
|
||||
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
|
||||
@@ -33,6 +38,7 @@ class SetpointServiceTest {
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
/** What the gateway does on the next calls to apply, in order: a Status, or an IOException; then APPLIED. */
|
||||
private final List<Object> script = new ArrayList<>();
|
||||
private final List<String> restored = new ArrayList<>();
|
||||
private boolean down;
|
||||
private final GatewayCommands gateway = new GatewayCommands() {
|
||||
@Override
|
||||
@@ -48,6 +54,15 @@ class SetpointServiceTest {
|
||||
return new CommandResult((Status) next, command.setpoint(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
if (down) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
restored.add(setpoint + " by " + requestedBy);
|
||||
return new RestoreResult(Outcome.RESTORED, "restored");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (down) {
|
||||
@@ -55,10 +70,29 @@ class SetpointServiceTest {
|
||||
}
|
||||
return STATE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
private final List<Preset> presets = PresetFile.load(null);
|
||||
private final SetpointService service = new SetpointService(gateway, presets);
|
||||
|
||||
@Test
|
||||
void restoreAsksTheGatewayInTheUsersName() throws Exception {
|
||||
assertEquals(Outcome.RESTORED, service.restore("cwu_economy", "anna").outcome());
|
||||
|
||||
assertEquals(List.of("cwu_economy by anna"), restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreWithTheGatewayDownIsAnIoError() {
|
||||
down = true;
|
||||
|
||||
assertThrows(IOException.class, () -> service.restore("cwu_economy", "anna"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewJoinsGatewayStateAndPresets() throws Exception {
|
||||
var view = service.current();
|
||||
@@ -93,33 +127,93 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void boostPresetSendsOneTimedCommand() throws Exception {
|
||||
var result = service.applyPreset("cwu-now", "anna").orElseThrow();
|
||||
var result = service.applyPreset("cwu-now", null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.APPLIED, result.status());
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsBothChangesUntilChanged() throws Exception {
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(2, result.results().size());
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, "anna"), new Command("co_comfort", 15, null, "anna")),
|
||||
sent);
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
new Command("co_comfort", 15, null, RETURN, "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
|
||||
service.applyPreset("away", RETURN, Map.of("co_comfort", 16), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
|
||||
service.applyPreset("cwu-now", null, Map.of("cwu_economy", 40), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("cwu_economy", 6), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
|
||||
for (int value : List.of(4, 26, 0)) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("co_comfort", value), "anna"));
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldValueThatDoesNotLowerTheCurrentOneWritesNothing() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of("co_comfort", 19), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldPresetThatWouldNotLowerAnySetpointWritesNothing() throws Exception {
|
||||
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 19, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
assertTrue(result.results().getLast().message().contains("not below the current 19"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIsEmpty() throws Exception {
|
||||
assertTrue(service.applyPreset("nope", "anna").isEmpty());
|
||||
assertTrue(service.applyPreset("nope", null, Map.of(), "anna").isEmpty());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetOutsideGatewayLimitsWritesNothing() throws Exception {
|
||||
var tooHot = new Preset("hot", "Za gorąco", List.of(new Change("co_comfort", 15, null),
|
||||
var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null),
|
||||
new Change("cwu_economy", 60, 2.0)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -129,9 +223,9 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void presetWithUnknownSetpointWritesNothing() throws Exception {
|
||||
var odd = new Preset("odd", "Dziwny", List.of(new Change("garage_heater", 1, null)));
|
||||
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertTrue(sent.isEmpty());
|
||||
@@ -145,7 +239,7 @@ class SetpointServiceTest {
|
||||
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
|
||||
script.add(Status.FAILED);
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -156,7 +250,7 @@ class SetpointServiceTest {
|
||||
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -166,7 +260,7 @@ class SetpointServiceTest {
|
||||
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
|
||||
script.add(Status.REJECTED);
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -176,7 +270,7 @@ class SetpointServiceTest {
|
||||
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
|
||||
@@ -186,7 +280,7 @@ class SetpointServiceTest {
|
||||
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
|
||||
script.add(new IOException("tunnel dropped"));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -198,7 +292,7 @@ class SetpointServiceTest {
|
||||
|
||||
assertThrows(IOException.class, service::current);
|
||||
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"id": "deye", "name": "Falownik Deye", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Bateria", "items": [
|
||||
{ "label": "Naładowanie", "value": 99.0, "unit": "%", "digits": 0 },
|
||||
{ "label": "Moc", "value": -24.0, "unit": "W", "digits": 0 },
|
||||
{ "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 }
|
||||
] },
|
||||
{ "title": "Odbiór domu", "items": [
|
||||
{ "label": "Moc razem", "value": 570.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Panele PV", "items": [
|
||||
{ "label": "PV1 moc", "value": 0.0, "unit": "W", "digits": 0 },
|
||||
{ "label": "PV2 moc", "value": 0.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Dziś", "items": [
|
||||
{ "label": "Produkcja PV", "value": 29.9, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Zużycie domu", "value": 24.2, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 },
|
||||
{ "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 }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fronius", "name": "Falownik Fronius", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Teraz", "items": [
|
||||
{ "label": "Moc PV", "value": 0.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Produkcja", "items": [
|
||||
{ "label": "Dziś", "value": 4.4, "unit": "kWh", "digits": 1 }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "house-meter", "name": "Licznik domu", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Moc czynna", "items": [
|
||||
{ "label": "Moc L1", "value": 56.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Napięcie", "items": [
|
||||
{ "label": "Napięcie L1", "value": 235.7, "unit": "V", "digits": 1 }
|
||||
] },
|
||||
{ "title": "Prąd", "items": [
|
||||
{ "label": "Prąd L1", "value": 0.32, "unit": "A", "digits": 2 }
|
||||
] },
|
||||
{ "title": "Współczynnik mocy", "items": [
|
||||
{ "label": "cos φ L1", "value": 0.75, "unit": "", "digits": 2 }
|
||||
] },
|
||||
{ "title": "Sieć", "items": [
|
||||
{ "label": "Częstotliwość", "value": 50.01, "unit": "Hz", "digits": 2 }
|
||||
] },
|
||||
{ "title": "Energia od początku", "items": [
|
||||
{ "label": "Pobrana", "value": 67514.0, "unit": "kWh", "digits": 0 }
|
||||
] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"time": "2026-10-23T22:14:00Z",
|
||||
"gatewayReachable": true,
|
||||
"backendVersion": "v1.3.0",
|
||||
"gatewayVersion": "v1.2.1",
|
||||
"devices": [
|
||||
{ "name": "heat-pump", "up": true, "lastSuccess": "2026-10-23T22:13:35Z", "lastError": null },
|
||||
{ "name": "deye", "up": false, "lastSuccess": null, "lastError": "connect timed out" }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"time": "2026-10-23T21:00:00Z",
|
||||
"stale": true,
|
||||
"temperature": null,
|
||||
"humidity": null,
|
||||
"pressure": null
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"stale": false,
|
||||
"temperature": 20.17,
|
||||
"humidity": 45.82,
|
||||
"pressure": 985.8
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "until": "2026-11-02T18:00:00+01:00" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "until": "2026-11-02T18:00:00+01:00", "values": { "cwu_comfort": 12, "co_comfort": 16 } }
|
||||
@@ -0,0 +1 @@
|
||||
{ "outcome": "RESTORED", "message": "restored to 8" }
|
||||
@@ -17,6 +17,7 @@
|
||||
{
|
||||
"id": "away",
|
||||
"name": "Wyjazd",
|
||||
"until": "chosen",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"status":"UP","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
|
||||
{"status":"UP","version":"v1.2.1","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
|
||||
@@ -0,0 +1,34 @@
|
||||
# ADR-011: Setpoint changes are leases that the backend renews
|
||||
|
||||
Status: accepted · 2026-10-24
|
||||
|
||||
## Context
|
||||
|
||||
ADR-006 restores a timed boost by itself, but a change "until changed" (away: CWU 10, CO 15)
|
||||
stays forever. If the backend, the tunnel or the Kraków server is lost for good, the house stays
|
||||
on those settings until someone notices. The requirement: a missing command at the gateway
|
||||
reverses the change.
|
||||
|
||||
## Decision
|
||||
|
||||
- Every applied change, timed or not, keeps a restore with a **lease** of 15 minutes
|
||||
(`leaseUntil`). The restore runs at the earlier of its end time (`due`, null for "until
|
||||
changed") and the end of the lease, through the existing path: conflict check, read-back,
|
||||
backoff.
|
||||
- `POST /commands/renew` (token, same throttle as `/commands`) extends the lease to 15 minutes
|
||||
from now for every restore that has not reached its run time. A lapsed, due or failing restore
|
||||
is never revived.
|
||||
- The backend calls it every 5 minutes from its start while `GATEWAY_TOKEN` is set. A failed call
|
||||
is logged and retried; two in a row may fail without effect.
|
||||
- A restore file entry without `leaseUntil` (older file) counts as lapsed and is restored on
|
||||
start. `leaseUntil` is saved atomically like the rest.
|
||||
- Limits and presets stay as agreed: CWU at most 48 °C (code ceiling 50), CO 5–25, restores at
|
||||
most 72 h.
|
||||
|
||||
## Consequences
|
||||
|
||||
- "Until changed" means "while the backend runs and reaches the gateway". The UI says so.
|
||||
- After a backend or tunnel outage longer than 15 minutes the house returns to the original
|
||||
values; the user sets them again. That is the intended safe direction.
|
||||
- One extra small file write on the Pi every 5 minutes while a change is active.
|
||||
- A backend that is up but wrong about the world keeps changes alive; the gateway cannot tell.
|
||||
@@ -0,0 +1,38 @@
|
||||
# ADR-012: Away is held until a chosen date, without renewals
|
||||
|
||||
Status: accepted · 2026-10-24
|
||||
|
||||
Amends [ADR-011](011-setpoint-leases.md) for held lowering changes; everything else there stands.
|
||||
|
||||
## Context
|
||||
|
||||
With ADR-011 the preset "Wyjazd" (CWU 10, CO 15) lasts only while the backend and the tunnel
|
||||
renew its lease. A server or tunnel outage during a trip would end the away mode and heat an
|
||||
empty house. The away values only lower temperatures, so keeping them is the safe direction.
|
||||
|
||||
## Decision
|
||||
|
||||
- The user picks a return date and time on a calendar. "Wyjazd" is held until then and the
|
||||
gateway restores the previous values by itself, with no renewals.
|
||||
- `POST /commands` takes an optional `until` (ISO-8601 with offset), exclusive with `minutes`.
|
||||
The change is stored as a restore with `leaseUntil = due = until`: no new file field, `runAt`
|
||||
is the end, and a restart keeps it. Backend: preset field `"until": "chosen"` and
|
||||
`POST /api/presets/{id}` with `{"until": …}`; 400 when it is missing, invalid or not wanted.
|
||||
- The gateway enforces the safety, not the backend. A held change is rejected, with no write,
|
||||
unless the end is in the future and at most 30 days ahead (`Command.MAX_HOLD`), and the new
|
||||
value is lower than the register's current value and lower than the original that will come
|
||||
back. Raising stays on renewed leases.
|
||||
- `renewLeases` only extends: `leaseUntil = max(leaseUntil, now + 15 min)`. A held change keeps
|
||||
its end, and a restore that is due, lapsed or failing is still never revived.
|
||||
- A pending boost: a held change may replace it, keeping the first original. Because it must be
|
||||
below that original, nothing above the original outlives the boost. A later boost on a held
|
||||
change is an ordinary lease again, which can only shorten the hold.
|
||||
- The restore at the end uses the usual path: conflict check, read-back, backoff.
|
||||
|
||||
## Consequences
|
||||
|
||||
- During a trip the house stays on away values even if the backend, tunnel or server are lost.
|
||||
If the gateway or the Pi is lost, the heat pump keeps the away values until someone
|
||||
intervenes; the 30 days and the lowering-only rule bound the cost.
|
||||
- A held away cannot be cancelled by lapsing; the user sets the values back, which replaces it.
|
||||
- Wyjazd is refused when a value is already at or below the away value (nothing to lower).
|
||||
@@ -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.
|
||||
+9
-9
@@ -27,9 +27,11 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|
||||
- [x] Config file outside the jar (D3), wire `Main`, drop modbus4j, influxdb-java and the
|
||||
baseline classes (D4, D5)
|
||||
- [x] Three parts with their images and compose (ADR-010); releases by tag, `update.sh` on the Pi
|
||||
- [ ] On the Pi, each step approved: D7, Docker, `gateway.json`, IIO overlay and reboot, stop
|
||||
`rpi-measurements` before the gateway polls the meter, first `./update.sh`, then retire both
|
||||
`rpi-measurements` and the old `home-pump.service`
|
||||
- [x] On the Pi (2026-10-24): D7, Docker (no iptables, no forwarding), `gateway.json`, ufw 8080
|
||||
on `wg0` only, `v1.1.1` running with the heat pump, Deye and Fronius; old service stopped
|
||||
- [x] On the Pi (2026-10-24): IIO overlay, BME280 and the meter in the gateway (`v1.2.1`: the
|
||||
meter's converter speaks RTU over TCP); `rpi-measurements` stopped and disabled
|
||||
- [ ] After the week: remove `rpi-measurements` and the old `home-pump.service` files
|
||||
|
||||
## Next
|
||||
|
||||
@@ -37,7 +39,8 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|
||||
|---|---|---|
|
||||
| M2 Understand the controller | Timing (safe poll rate, response times), which pumps run in which state, open questions in `docs/frisko/` | `docs/frisko/` reviewed; F3 limits agreed |
|
||||
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; Traefik route, Krosno only and no login for now (ADR-009). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); open: `REGISTRY_TOKEN`, first release, the stack in homelab-compose (Arcane) | My father sees the heat pump and the energy on his phone |
|
||||
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
|
||||
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011), 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 |
|
||||
|
||||
@@ -51,11 +54,8 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|
||||
- D6 Development runs wrote test fields into the production `Dom_Pompa` database.
|
||||
- D8 Each field is written as its own point with its own timestamp, so one poll can split into
|
||||
points 1 ms apart (`InfluxMetricModbusSender`); write one point per poll.
|
||||
- D7 Pi network: all 880 DNS errors of the week came in one hour (19.10, 10:00), likely an outage,
|
||||
not config. But the Pi's WireGuard routes most of `192.168.0.0/24` into the tunnel (Kraków uses
|
||||
the same subnet), so it reaches only `.10` and `.150` locally. Fix: Pi `AllowedIPs` only
|
||||
`10.0.0.0/24, 10.2.0.0/24` (Influx and Gitea go via the public IP anyway), plus outbound NAT on
|
||||
OPNsense's WireGuard interface so the Pi answers Kraków through the tunnel.
|
||||
- D7 fixed 2026-10-24: Pi `AllowedIPs` only `10.0.0.0/24, 10.2.0.0/24`, and OPNsense NATs
|
||||
`192.168.0.0/24 → 10.0.0.2` to its tunnel address, as both sites use `192.168.0.0/24`.
|
||||
|
||||
## Knowledge
|
||||
|
||||
|
||||
+14
-7
@@ -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.
|
||||
@@ -41,7 +41,7 @@ Out, until decided otherwise:
|
||||
| Solar thermal collector | roof | Also heats the CWU tank; not connected to anything we read |
|
||||
| Hybrid inverter Deye SUN-10K-SG04LP3-EU | logger `192.168.0.232:8899` | 10 kW, 3 phases, 48 V battery (backup only); most of the PV |
|
||||
| PV inverter Fronius Symo 3.7-3-M | `192.168.0.143` | 4.2 kWp; local Solar API (JSON) |
|
||||
| House meter SDM630 | RS485 gateway `192.168.0.10:502` | Whole house; read today by `rpi-measurements` into Influx `electricity`/`PRAD2`, moving into our gateway |
|
||||
| House meter SDM630 | RS485 gateway `192.168.0.10:502` | Whole house; read today by `rpi-measurements` into Influx `electricity`/`PRAD2` (unchanged), moving into our gateway; per-phase energy counters go to `electricity`/`house_meter_phases` |
|
||||
| Solar-Log | `192.168.0.210` | Old monitoring, no data since April 2026 |
|
||||
| Raspberry Pi (`raspberrypi`) | `192.168.0.158` | Debian 11 (kept as is); runs the gateway (and `rpi-measurements` until the move); BME280 sensor; WireGuard `10.0.0.2` |
|
||||
| My server | Kraków | Proxmox and Docker: backend, Traefik, InfluxDB 1.x, Grafana, Gitea. Same subnet `192.168.0.0/24` as Krosno; WireGuard on OPNsense |
|
||||
@@ -74,19 +74,26 @@ Layers in both apps, dependencies point inwards only:
|
||||
Functional:
|
||||
|
||||
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
|
||||
heater, alarm, current targets and temperatures.
|
||||
heater, alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
|
||||
pressure) of the living room.
|
||||
- F2 History for the last 7 days.
|
||||
- F3 Change four setpoints: economy and comfort temperature of CWU (4143, 4144) and CO
|
||||
(4131, 4132). Any value within limits, or one tap on a named preset. Limits and presets live in
|
||||
the backend config: CWU 5–50 °C (pump maximum per my father; 48 °C proposed for margin, to
|
||||
decide in M2), CO 5–25 °C.
|
||||
the backend config: CWU 5–48 °C (decided; the gateway's ceiling stays 50), CO 5–25 °C.
|
||||
- **Boost**: raise the economy temperature for a duration the user picks; the previous value
|
||||
is restored automatically.
|
||||
- **Away / not needed**: lower the comfort temperature (e.g. CWU 10 °C, CO 15 °C) until changed back.
|
||||
- **Away**: lower the comfort temperature (CWU 10 °C, CO 15 °C by default, lower if the user chooses) until a return date and time the
|
||||
user picks (at most 30 days). Held by the gateway without renewals (ADR-012), lowering only.
|
||||
- **Lease** (ADR-011): every other change also needs the backend to renew it every 5 minutes; after
|
||||
15 minutes without, the gateway restores the previous value.
|
||||
- **Restore now**: the user can end any pending change at once; the previous value comes back
|
||||
through the same checks.
|
||||
- F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery).
|
||||
- F5 Health of backend, device links and tunnel.
|
||||
- F6 Later, low priority: show the weekly program (reading needs selector writes).
|
||||
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals.
|
||||
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals; a details panel per device (Deye, Fronius, house meter); 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:
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ WORKDIR /src
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY . .
|
||||
# The release tag, shown in the Stan screen; the release workflow passes it.
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=$VERSION
|
||||
# Tests run in scripts/check.sh; they read the API contract from backend/, outside this context.
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -13,3 +13,13 @@ docker compose -f frontend/compose.yaml up --build # after the backend's; .env
|
||||
|
||||
The overview picture is `research/ui/house-cutaway.webp`; `python3 scripts/house-assets.py` makes
|
||||
the sizes and the pipe masks in `src/assets/` from it.
|
||||
|
||||
The release tag is baked in at build (`VERSION` build arg, `dev` by default) and shown with the backend's
|
||||
and gateway's versions in the "Wersje" card of the Stan screen.
|
||||
|
||||
The Energia screen has three views, kept in the URL hash so Back works: `#energy` (Teraz), `#energy/battery` (Bateria, also
|
||||
opened by tapping the battery in the flow diagram) and `#energy/inverters` (Falowniki, the device details).
|
||||
|
||||
In the Bateria view, `editable` from `api/battery` (the gateway takes battery changes, ADR-013) adds the reserve stepper and "Edytuj" for the
|
||||
schedule: the user confirms a summary, only changed fields are sent to `api/battery/schedule` and `api/battery/reserve`, and the UI
|
||||
checks the gateway's rules first (levels from the reserve to 100 %, starts increasing).
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#0e2a21">
|
||||
<title>Dom Krosno</title>
|
||||
<title>Dom Szczepańcowa</title>
|
||||
<link rel="icon" href="data:,">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ function SideFooter() {
|
||||
<Icon name="sun" className="size-9 text-amber-400" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
|
||||
<p className="text-sm text-white/60">Krosno</p>
|
||||
<p className="text-sm text-white/60">Szczepańcowa</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
|
||||
@@ -97,7 +97,7 @@ export function App() {
|
||||
<div className="mb-8 hidden items-center gap-3 px-2 lg:flex">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-6" /></span>
|
||||
<div>
|
||||
<p className="text-lg font-bold leading-tight">Dom Krosno</p>
|
||||
<p className="text-lg font-bold leading-tight">Dom Szczepańcowa</p>
|
||||
<p className="text-xs text-white/60">Pompa ciepła i energia</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { quantity, when } from "./format";
|
||||
import type { EnergyDetailsDevice } from "./types";
|
||||
import { Row } from "./ui";
|
||||
|
||||
/** The tappable row that says in words and with a chevron that a device has more to show. */
|
||||
export function DetailsButton({ device, onOpen }: { device: EnergyDetailsDevice; onOpen: () => void }) {
|
||||
return (
|
||||
<button type="button" onClick={onOpen}
|
||||
className="flex min-h-14 w-full items-center justify-between gap-3 rounded-xl border border-edge px-4 py-2 text-left hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
|
||||
<span>
|
||||
<span className="block font-semibold">{device.name}</span>
|
||||
{device.stale && <span className="block text-xs text-soft">brak odczytu</span>}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 font-semibold text-brand">
|
||||
Szczegóły <span aria-hidden="true" className="text-xl leading-none">›</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A bottom sheet on a phone, a dialog on a wide screen; closes by button, backdrop or Escape. */
|
||||
export function DeviceDetails({ device, onClose }: { device: EnergyDetailsDevice; onClose: () => void }) {
|
||||
const close = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const opener = document.activeElement as HTMLElement | null;
|
||||
const overflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
close.current?.focus();
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
// The close button is the only control inside, so Tab stays on it.
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
close.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = overflow;
|
||||
opener?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div data-testid="backdrop" className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center"
|
||||
onClick={onClose}>
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="details-title" onClick={(event) => event.stopPropagation()}
|
||||
className="flex max-h-[85dvh] w-full flex-col rounded-t-2xl border border-edge bg-panel shadow-xl sm:max-w-lg sm:rounded-2xl">
|
||||
<header className="flex items-start justify-between gap-3 border-b border-edge p-4">
|
||||
<div>
|
||||
<h2 id="details-title" className="text-lg font-semibold">{device.name}</h2>
|
||||
{device.time && <p className="text-sm text-soft">Odczyt z {when(device.time)}</p>}
|
||||
</div>
|
||||
<button ref={close} type="button" onClick={onClose}
|
||||
className="min-h-11 min-w-11 rounded-xl px-3 font-semibold text-brand hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
|
||||
Zamknij
|
||||
</button>
|
||||
</header>
|
||||
<div className="overflow-y-auto p-4">
|
||||
{device.groups.length === 0 && (
|
||||
<p className="text-soft">
|
||||
Brak aktualnych danych{device.time ? ` (ostatni odczyt ${when(device.time)})` : ""}.
|
||||
</p>
|
||||
)}
|
||||
{device.groups.map((group) => (
|
||||
<section key={group.title} className="mb-4 last:mb-0">
|
||||
<h3 className="mb-1 text-sm font-semibold uppercase tracking-wide text-soft">{group.title}</h3>
|
||||
{group.items.map((item) => (
|
||||
<Row key={item.label} label={item.label} value={quantity(item.value, item.unit, item.digits)} />
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { Energy as EnergyData, EnergyDetails } from "./types";
|
||||
|
||||
// A fresh module per test: the poller keeps its answers between screens, not between tests.
|
||||
const energy = async () => (await import("./Energy")).Energy;
|
||||
|
||||
function serveEnergy(details: EnergyDetails = example("energy-details")) {
|
||||
serve({ "api/energy/details": details, "api/energy": example("energy"), "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 />);
|
||||
|
||||
for (const name of ["Falownik Deye", "Falownik Fronius", "Licznik domu"]) {
|
||||
expect(await screen.findByRole("button", { name: new RegExp(`${name}.*Szczegóły`) })).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the groups of a device and closes by button, backdrop and Escape", async () => {
|
||||
serveEnergy();
|
||||
location.hash = "energy/inverters";
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
const opener = await screen.findByRole("button", { name: /Falownik Deye/ });
|
||||
await userEvent.click(opener);
|
||||
const dialog = screen.getByRole("dialog", { name: "Falownik Deye" });
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
expect(within(dialog).getByRole("button", { name: "Zamknij" })).toHaveFocus();
|
||||
expect(within(dialog).getByRole("heading", { name: "Bateria" })).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("Naładowanie").parentElement).toHaveTextContent("99 %");
|
||||
expect(within(dialog).getByText("Pobrano z sieci").parentElement).toHaveTextContent("16,8 kWh");
|
||||
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Zamknij" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(opener).toHaveFocus();
|
||||
|
||||
await userEvent.click(opener);
|
||||
await userEvent.click(screen.getByTestId("backdrop"));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
await userEvent.click(opener);
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("stays open when the panel itself is tapped", async () => {
|
||||
serveEnergy();
|
||||
location.hash = "energy/inverters";
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Licznik domu/ }));
|
||||
await userEvent.click(screen.getByRole("heading", { name: "Licznik domu" }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says there is no current data for a device without a fresh reading", async () => {
|
||||
const details = example<EnergyDetails>("energy-details");
|
||||
serveEnergy({ devices: details.devices.map((d) => (d.id === "fronius" ? { ...d, stale: true, groups: [] } : d)) });
|
||||
location.hash = "energy/inverters";
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Falownik Fronius/ }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Brak aktualnych danych");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Energy flow arrows", () => {
|
||||
async function arrows(patch: Partial<EnergyData>) {
|
||||
serve({ "api/energy/details": example("energy-details"), "api/energy": { ...example<EnergyData>("energy"), ...patch } });
|
||||
const Energy = await energy();
|
||||
const { container } = render(<Energy />);
|
||||
await screen.findByText("Energia teraz");
|
||||
const on = (flow: string) => container.querySelector(`[data-flow="${flow}-house"] [data-arrow]`);
|
||||
return { on, container };
|
||||
}
|
||||
const angle = (el: Element | null) => /rotate\((-?[\d.]+)\)/.exec(el!.getAttribute("transform")!)![1];
|
||||
|
||||
it("points from the grid to the house on import and back on export", async () => {
|
||||
const imported = await arrows({ gridPowerW: 645 });
|
||||
expect(Number(angle(imported.on("grid")))).toBeGreaterThan(90);
|
||||
expect(screen.getByText("Sieć zasila dom: 645 W")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
const exported = await arrows({ gridPowerW: -800 });
|
||||
expect(Math.abs(Number(angle(exported.on("grid"))))).toBeLessThan(90);
|
||||
expect(screen.getByText(/Dom oddaje do sieci: 800 W/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("points from the battery to the house on discharge and the other way on charge", async () => {
|
||||
const discharging = await arrows({ batteryPowerW: 300 });
|
||||
expect(Number(angle(discharging.on("battery")))).toBe(-90);
|
||||
expect(screen.getByText(/Bateria zasila dom/)).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
const charging = await arrows({ batteryPowerW: -300 });
|
||||
expect(Number(angle(charging.on("battery")))).toBe(90);
|
||||
expect(screen.getByText(/Dom ładuje baterię/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no arrow for about 0 W or a missing value", async () => {
|
||||
const quiet = await arrows({ pvPowerW: 3, gridPowerW: -4, batteryPowerW: 0 });
|
||||
for (const flow of ["pv", "grid", "battery"]) expect(quiet.on(flow)).toBeNull();
|
||||
expect(screen.getByText("Bateria: bez przepływu")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
const missing = await arrows({ pvPowerW: null, gridPowerW: null, batteryPowerW: null });
|
||||
for (const flow of ["pv", "grid", "battery"]) expect(missing.on(flow)).toBeNull();
|
||||
});
|
||||
|
||||
it("points from the sun to the house", async () => {
|
||||
const sunny = await arrows({ pvPowerW: 2500 });
|
||||
expect(Number(angle(sunny.on("pv")))).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Słońce zasila dom: 2,5 kW")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+116
-14
@@ -1,11 +1,14 @@
|
||||
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";
|
||||
import type { Energy as EnergyData } from "./types";
|
||||
import type { Energy as EnergyData, EnergyDetails } from "./types";
|
||||
import { DetailsButton, DeviceDetails } from "./DeviceDetails";
|
||||
import { Banner, Card, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
|
||||
const FLOWING_W = 10;
|
||||
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. */
|
||||
@@ -17,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.
|
||||
@@ -41,6 +43,13 @@ const NODES: Record<"pv" | "grid" | "house" | "battery", { x: number; y: number;
|
||||
battery: { x: 300, y: 300, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
|
||||
};
|
||||
|
||||
// A triangle halfway along the line, pointing at the sink: the direction is readable without the moving dots.
|
||||
function Arrowhead({ from, to }: { from: { x: number; y: number; colour: string }; to: { x: number; y: number } }) {
|
||||
const angle = (Math.atan2(to.y - from.y, to.x - from.x) * 180) / Math.PI;
|
||||
const x = (from.x + to.x) / 2, y = (from.y + to.y) / 2;
|
||||
return <polygon data-arrow points="-11,-10 11,0 -11,10" fill={from.colour} transform={`translate(${x} ${y}) rotate(${angle})`} />;
|
||||
}
|
||||
|
||||
function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof NODES; watts: number | null }) {
|
||||
const a = NODES[from], b = NODES[to];
|
||||
const on = flowing(watts);
|
||||
@@ -49,37 +58,64 @@ 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} />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowPicture({ e }: { e: EnergyData }) {
|
||||
// The picture in words, for screen readers: who gives to whom.
|
||||
export function flowSummary(e: EnergyData) {
|
||||
const w = (watts: number | null) => power(magnitude(watts));
|
||||
return [
|
||||
flowing(e.pvPowerW) ? `Słońce zasila dom: ${w(e.pvPowerW)}` : "Słońce: brak produkcji",
|
||||
!flowing(e.gridPowerW) ? "Sieć: bez wymiany"
|
||||
: e.gridPowerW! > 0 ? `Sieć zasila dom: ${w(e.gridPowerW)}` : `Dom oddaje do sieci: ${w(e.gridPowerW)}`,
|
||||
!flowing(e.batteryPowerW) ? "Bateria: bez przepływu"
|
||||
: e.batteryPowerW! > 0 ? `Bateria zasila dom: ${w(e.batteryPowerW)}` : `Dom ładuje baterię: ${w(e.batteryPowerW)}`,
|
||||
];
|
||||
}
|
||||
|
||||
function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void }) {
|
||||
return (
|
||||
<svg viewBox="0 0 600 350" className="h-auto w-full" role="img" aria-label="Przepływ energii w domu">
|
||||
<>
|
||||
<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">
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,18 +174,33 @@ function Stat({ icon, label, value, note }: { icon: IconName; label: string; val
|
||||
);
|
||||
}
|
||||
|
||||
export function Energy() {
|
||||
function Devices() {
|
||||
const { data } = usePoll<EnergyDetails>("api/energy/details");
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const close = useCallback(() => setOpenId(null), []);
|
||||
const open = data?.devices.find((d) => d.id === openId);
|
||||
return (
|
||||
<Card title="Urządzenia">
|
||||
{!data ? <Loading /> : (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{data.devices.map((d) => <DetailsButton key={d.id} device={d} onOpen={() => setOpenId(d.id)} />)}
|
||||
</div>
|
||||
)}
|
||||
{open && <DeviceDetails device={open} onClose={close} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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"} />
|
||||
@@ -161,3 +212,54 @@ export function Energy() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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,12 +3,14 @@ import { describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { HeatPump } from "./types";
|
||||
|
||||
import { PI_SENSOR_TITLE } from "./Overview";
|
||||
const overview = async () => (await import("./Overview")).Overview;
|
||||
const fresh = (hp: HeatPump) => ({ ...hp, time: new Date().toISOString() });
|
||||
const history = { series: "x", unit: "°C", points: [] };
|
||||
|
||||
function serveHeatPump(hp: HeatPump) {
|
||||
return serve({ "api/heat-pump": hp, "api/energy": example("energy"), "api/history": history });
|
||||
return serve({ "api/heat-pump": hp, "api/energy": example("energy"), "api/history": history,
|
||||
"api/pi-sensor": example("pi-sensor") });
|
||||
}
|
||||
|
||||
const flowing = (container: HTMLElement) =>
|
||||
@@ -17,6 +19,48 @@ const glowing = (container: HTMLElement) =>
|
||||
[...container.querySelectorAll("[data-glow][data-on=true]")].map((p) => p.getAttribute("data-glow"));
|
||||
|
||||
describe("Overview", () => {
|
||||
it("shows the sensor of the Raspberry Pi in the living room", async () => {
|
||||
serveHeatPump(fresh(example("heat-pump")));
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
|
||||
expect(card).toHaveTextContent("Temperatura20,2 °C");
|
||||
expect(card).toHaveTextContent("Wilgotność45,8 %");
|
||||
expect(card).toHaveTextContent("Ciśnienie986 hPa");
|
||||
});
|
||||
|
||||
it("says there is no reading when the sensor is stale", async () => {
|
||||
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
|
||||
"api/pi-sensor": example("pi-sensor-stale") });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
|
||||
expect(card).toHaveTextContent("Temperaturabrak odczytu");
|
||||
expect(card).toHaveTextContent("Wilgotnośćbrak odczytu");
|
||||
expect(card).toHaveTextContent("Ciśnieniebrak odczytu");
|
||||
});
|
||||
|
||||
it("shows a running part in the red accent and a stopped one in grey", async () => {
|
||||
const hp = fresh(example<HeatPump>("heat-pump"));
|
||||
serveHeatPump({ ...hp, state: "HEATING_CWU", compressorPercent: 60, pumps: { ...hp.pumps, cwu: true } });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
expect(await screen.findByTitle("Pompa CWU: pracuje")).toHaveClass("bg-run-soft");
|
||||
expect(screen.getByTitle("Pompa CO: stoi")).not.toHaveClass("bg-run-soft");
|
||||
expect(screen.getAllByText("Grzeje wodę (CWU)")[0].closest("span")).toHaveClass("text-run");
|
||||
});
|
||||
|
||||
it("keeps the alarm solid, unlike the red of a running part", async () => {
|
||||
serveHeatPump({ ...fresh(example("heat-pump")), alarm: true });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
expect((await screen.findAllByText("Alarm"))[0].closest("span")).toHaveClass("bg-alarm", "text-white");
|
||||
});
|
||||
|
||||
it("draws a resting pump with nothing flowing", async () => {
|
||||
serveHeatPump(fresh(example("heat-pump")));
|
||||
const Overview = await overview();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { celsius, degrees, hour, longDate, percent, power } from "./format";
|
||||
import { celsius, degrees, hour, longDate, number, percent, power } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import { Schematic } from "./Schematic";
|
||||
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
|
||||
import type { Energy, HeatPump, History, PumpState } from "./types";
|
||||
import type { Energy, HeatPump, History, PiSensor, PumpState } from "./types";
|
||||
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
import { EnergyToday, gridText } from "./Energy";
|
||||
|
||||
@@ -32,9 +32,9 @@ const TEMPERATURE_LINES: Line[] = [
|
||||
const onOff = (on: boolean | null, yes: string, no: string) => (on == null ? "—" : on ? yes : no);
|
||||
|
||||
function StatusPill({ hp }: { hp: HeatPump }) {
|
||||
if (hp.alarm) return <Pill tone="bad" live>Alarm</Pill>;
|
||||
if (hp.alarm) return <Pill tone="bad" live><Icon name="alert" className="size-3.5" />Alarm</Pill>;
|
||||
if (hp.state == null) return <Pill tone="off">Stan nieznany</Pill>;
|
||||
return hp.state === "IDLE" ? <Pill tone="ok">Praca prawidłowa</Pill> : <Pill tone="hot" live>{HEADLINES[hp.state]}</Pill>;
|
||||
return hp.state === "IDLE" ? <Pill tone="off">Praca prawidłowa</Pill> : <Pill tone="run" live>{HEADLINES[hp.state]}</Pill>;
|
||||
}
|
||||
|
||||
function Tile({ icon, title, value, note, status, children }: {
|
||||
@@ -59,7 +59,7 @@ function Tile({ icon, title, value, note, status, children }: {
|
||||
}
|
||||
|
||||
const running = (on: boolean | null, yes = "Pracuje") =>
|
||||
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="ok" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
|
||||
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
|
||||
|
||||
function Temperatures({ hp }: { hp: HeatPump }) {
|
||||
const t = hp.temperatures;
|
||||
@@ -80,7 +80,7 @@ function Temperatures({ hp }: { hp: HeatPump }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Every moving part as a chip under the picture: what runs now is green and pulses.
|
||||
// Every moving part as a chip under the picture: what runs now is red and pulses.
|
||||
function Devices({ hp }: { hp: HeatPump }) {
|
||||
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
|
||||
const items: [string, boolean | null, string][] = [
|
||||
@@ -95,11 +95,11 @@ function Devices({ hp }: { hp: HeatPump }) {
|
||||
<ul aria-label="Urządzenia" className="mt-3 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
{items.map(([name, on, text]) => (
|
||||
<li key={name} title={`${name}: ${text}`}
|
||||
className={`flex items-center gap-2 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs sm:text-sm ${on ? "border-brand/30 bg-brand-soft" : "border-edge text-soft"}`}>
|
||||
<Dot tone={on ? "ok" : "off"} live={!!on} />
|
||||
className={`flex items-center gap-2 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs sm:text-sm ${on ? "border-run/30 bg-run-soft" : "border-edge text-soft"}`}>
|
||||
<Dot tone={on ? "run" : "off"} live={!!on} />
|
||||
<span>{name}</span>
|
||||
{/* On a phone the colour says it; the word needs the room of a larger screen. */}
|
||||
<b className={`hidden sm:inline ${on ? "text-brand" : "text-soft"}`}>{text}</b>
|
||||
<b className={`hidden sm:inline ${on ? "text-run" : "text-soft"}`}>{text}</b>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -119,6 +119,24 @@ function Targets({ hp }: { hp: HeatPump }) {
|
||||
);
|
||||
}
|
||||
|
||||
// The Raspberry Pi with the BME280 stands in the living room.
|
||||
export const PI_SENSOR_TITLE = "Salon";
|
||||
|
||||
const NO_READING = "brak odczytu";
|
||||
const reading = (value: number | null | undefined, digits: number, unit: string) =>
|
||||
value == null ? NO_READING : `${number(value, digits)} ${unit}`;
|
||||
|
||||
function PiSensorCard() {
|
||||
const { data } = usePoll<PiSensor>("api/pi-sensor");
|
||||
return (
|
||||
<Card title={PI_SENSOR_TITLE}>
|
||||
<Row label="Temperatura" value={reading(data?.temperature, 1, "°C")} />
|
||||
<Row label="Wilgotność" value={reading(data?.humidity, 1, "%")} />
|
||||
<Row label="Ciśnienie" value={reading(data?.pressure, 0, "hPa")} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function useTemperatureHistory() {
|
||||
const cwu = usePoll<History>("api/history?series=cwu_temp&range=24h", HISTORY_MS);
|
||||
const co = usePoll<History>("api/history?series=co_buffer_temp&range=24h", HISTORY_MS);
|
||||
@@ -211,6 +229,7 @@ export function Overview() {
|
||||
</Tile>
|
||||
</div>
|
||||
<TemperatureChart />
|
||||
<PiSensorCard />
|
||||
<EnergyToday e={e} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -111,10 +111,10 @@ export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | n
|
||||
{/* Photovoltaics: a light sweeps over the panels while they produce */}
|
||||
{sunny && <g clipPath="url(#pv)" data-pv="on"><rect className="sweep" x={985} y={180} width={400} height={160} fill="url(#sweep)" /></g>}
|
||||
|
||||
<Glow id="heat-pump" x={506} y={422} w={102} h={188} colour="#10b981" on={compressor} />
|
||||
<Glow id="heat-pump" x={506} y={422} w={102} h={188} colour="var(--color-run)" on={compressor} />
|
||||
<Glow id="cwu" x={639} y={402} w={90} h={216} colour="#f97316" on={cwu} />
|
||||
<Glow id="buffer" x={746} y={489} w={70} h={134} colour="#ef4444" on={buffer} />
|
||||
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="#22c55e" />}
|
||||
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="var(--color-run)" />}
|
||||
{hp.heaterOn && <rect className="glow" x={518} y={560} width={78} height={14} rx={7} fill="#ef4444" />}
|
||||
|
||||
{/* Warmth rising from the floor while the CO pump runs */}
|
||||
@@ -124,7 +124,7 @@ export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | n
|
||||
))}
|
||||
|
||||
<Callout x={36} y={40} title="Na zewnątrz" value={celsius(t.outdoor)} />
|
||||
<Callout x={210} y={400} title="Pompa ciepła" to={[512, 470]} tone={compressor ? "var(--color-brand)" : undefined}
|
||||
<Callout x={210} y={400} title="Pompa ciepła" to={[512, 470]} tone={compressor ? "var(--color-run)" : undefined}
|
||||
value={compressor ? `sprężarka ${percent(hp.compressorPercent)}` : "stoi"} />
|
||||
<Callout x={540} y={215} title="Ciepła woda (CWU)" to={[684, 410]} tone={cwu ? "#ea580c" : undefined}
|
||||
value={<>{celsius(t.cwu)}{target(targets.cwu)}</>} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { answer, example, serve } from "./fixtures";
|
||||
import { localInput } from "./format";
|
||||
import type { Setpoints } from "./types";
|
||||
|
||||
// A fresh module per test: the poller keeps its answers between screens, not between tests.
|
||||
@@ -60,10 +61,10 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania."))).toBeInTheDocument();
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, ustaw" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
|
||||
expect(posts(fetch)[0][0]).toBe("api/presets/away");
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Tylko część zmian jest zapisana.");
|
||||
@@ -71,6 +72,138 @@ describe("Settings", () => {
|
||||
expect(alert).toHaveTextContent("Ogrzewanie domu, komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
|
||||
});
|
||||
|
||||
it("asks for the return time of a trip and sends the chosen time with the local offset", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
const input = screen.getByLabelText("Powrót") as HTMLInputElement;
|
||||
expect(input.type).toBe("datetime-local");
|
||||
const hours = (value: string) => (Date.parse(value) - Date.now()) / 3600_000;
|
||||
expect(hours(input.min)).toBeGreaterThan(0.9);
|
||||
expect(hours(input.min)).toBeLessThan(1.1);
|
||||
expect(hours(input.max) / 24).toBeGreaterThan(29.9);
|
||||
expect(hours(input.max) / 24).toBeLessThan(30.1);
|
||||
expect(input.value.endsWith("T18:00")).toBe(true);
|
||||
expect(hours(input.value) / 24).toBeGreaterThan(6);
|
||||
expect(hours(input.value) / 24).toBeLessThan(8);
|
||||
|
||||
const chosen = localInput(new Date(Date.now() + 3 * 24 * 3600_000));
|
||||
fireEvent.change(input, { target: { value: chosen } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
|
||||
|
||||
const sent = JSON.parse(String(posts(fetch)[0][1]!.body)).until as string;
|
||||
expect(sent).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:00[+-]\d\d:\d\d$/);
|
||||
expect(Date.parse(sent)).toBe(new Date(chosen).getTime());
|
||||
expect(await screen.findByText(/^Zapisano\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the preset temperatures in the trip form and sends the ones the user chose", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getAllByRole("status").map((o) => o.textContent)).toEqual(["10 °C", "15 °C"]);
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort"));
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 12 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
|
||||
|
||||
const body = JSON.parse(String(posts(fetch)[0][1]!.body));
|
||||
expect(body.values).toEqual({ cwu_comfort: 12, co_comfort: 14 });
|
||||
});
|
||||
|
||||
it("never lets a trip temperature reach the current value", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.setpoints.find((s) => s.name === "co_comfort")!.value = 15;
|
||||
serve({ "api/setpoints": data });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByText(paragraph("Ogrzewanie domu, komfort: 14 °C do powrotu, potem wróci 15 °C."))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables a trip temperature that cannot be lowered and the confirmation with it", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.setpoints.find((s) => s.name === "co_comfort")!.value = 5;
|
||||
serve({ "api/setpoints": data });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByText("Już na minimum (5 °C), nie da się obniżyć.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ustaw wyjazd" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("does not send a return time that is too early or too late", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
for (const days of [0.01, 31]) {
|
||||
fireEvent.change(screen.getByLabelText("Powrót"), { target: { value: localInput(new Date(Date.now() + days * 24 * 3600_000)) } });
|
||||
expect(screen.getByRole("button", { name: "Ustaw wyjazd" })).toBeDisabled();
|
||||
}
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("closes the return form on cancel without sending anything", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Anuluj" }));
|
||||
|
||||
expect(screen.queryByLabelText("Powrót")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Wyjazd" })).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the plain confirmation for a preset without a chosen end", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Ciepła woda teraz" }));
|
||||
expect(screen.queryByLabelText("Powrót")).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, ustaw" }));
|
||||
|
||||
expect(posts(fetch)[0][0]).toBe("api/presets/cwu-now");
|
||||
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({});
|
||||
});
|
||||
|
||||
it("shows a held change with its end date and the value that comes back", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.restores = [{ setpoint: "cwu_comfort", register: 4144, originalValue: 43, appliedValue: 10, due: "2030-11-02T17:00:00Z" }];
|
||||
serve({ "api/setpoints": data });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels a change without an end time as lasting while the server runs", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.restores = [{ setpoint: "cwu_comfort", register: 4144, originalValue: 45, appliedValue: 10, due: null }];
|
||||
serve({ "api/setpoints": data });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not offer a change built on a missing reading", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.setpoints[2].value = null;
|
||||
@@ -82,4 +215,73 @@ describe("Settings", () => {
|
||||
expect(screen.getByLabelText("Mniej: Ciepła woda, oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Ciepła woda teraz (brak odczytu)" })).toBeDisabled();
|
||||
});
|
||||
|
||||
describe("restore now", () => {
|
||||
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda, oszczędnie" });
|
||||
const restoreAnswer = (outcome: string, status: number) => () => answer({ outcome, message: "x" }, status);
|
||||
|
||||
it("asks first, then restores and reloads the state", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, restoreAnswer("RESTORED", 200));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await restoreButton());
|
||||
expect(screen.getByText("Przywrócić 8 °C teraz?")).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
|
||||
const loads = fetch.mock.calls.length;
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
|
||||
expect(posts(fetch)).toHaveLength(1);
|
||||
expect(posts(fetch)[0][0]).toBe("api/setpoints/cwu_economy/restore");
|
||||
expect(await screen.findByText("Przywrócono poprzednią wartość.")).toBeInTheDocument();
|
||||
expect(fetch.mock.calls.length).toBeGreaterThan(loads + 1);
|
||||
});
|
||||
|
||||
it("sends nothing when cancelled", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, restoreAnswer("RESTORED", 200));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await restoreButton());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Anuluj" }));
|
||||
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
expect(await restoreButton()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has a button large enough to tap", async () => {
|
||||
serve({ "api/setpoints": example("setpoints") });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect((await restoreButton()).className).toContain("min-h-12");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CONFLICT", 409, "zostawiliśmy ją bez zmian"],
|
||||
["TOO_SOON", 429, "Za wcześnie"],
|
||||
["FAILED", 502, "Zmiana nadal czeka"],
|
||||
["NOT_PENDING", 404, "już się zakończyła"],
|
||||
])("explains %s in Polish", async (outcome, status, text) => {
|
||||
serve({ "api/setpoints": example("setpoints") }, restoreAnswer(outcome, status));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await restoreButton());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it("never claims nothing changed when the answer is lost", async () => {
|
||||
serve({ "api/setpoints": example("setpoints") }, () => Promise.reject(new TypeError("network")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await restoreButton());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Nie wiadomo, czy zmiana doszła do pompy");
|
||||
});
|
||||
});
|
||||
});
|
||||
+130
-39
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { postJson, WriteError } from "./api";
|
||||
import { degrees, duration, when } from "./format";
|
||||
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
|
||||
import { reload, usePoll } from "./poll";
|
||||
import type { Change, CommandResult, LineStatus, Setpoint, Setpoints } from "./types";
|
||||
import { Card, Freshness, Loading, PageTitle } from "./ui";
|
||||
import type { Change, CommandResult, LineStatus, Preset, RestoreOutcome, RestoreResult, Setpoint, Setpoints } from "./types";
|
||||
import { Button, Card, Freshness, Loading, PageTitle, Stepper } from "./ui";
|
||||
|
||||
const URL = "api/setpoints";
|
||||
const NAMES: Record<string, string> = {
|
||||
@@ -27,6 +27,13 @@ const RESULT_TEXT: Record<string, string> = {
|
||||
FAILED: "Nie wiadomo, czy zmiana doszła do pompy. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
|
||||
PARTIAL: "Tylko część zmian jest zapisana. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
|
||||
};
|
||||
const RESTORE_TEXT: Record<RestoreOutcome, string> = {
|
||||
RESTORED: "Przywrócono poprzednią wartość.",
|
||||
NOT_PENDING: "Ta zmiana już się zakończyła. Nic nie zostało zmienione.",
|
||||
CONFLICT: "Wartość została w międzyczasie zmieniona (np. na panelu pompy), więc zostawiliśmy ją bez zmian.",
|
||||
TOO_SOON: "Za wcześnie po poprzednim zapisie. Spróbuj za minutę.",
|
||||
FAILED: "Nie udało się przywrócić. Zmiana nadal czeka, spróbujemy ponownie sami. Sprawdź wartości poniżej.",
|
||||
};
|
||||
const LINE_TEXT: Record<LineStatus, string> = {
|
||||
APPLIED: "zapisano",
|
||||
REJECTED: "pompa nie przyjęła zmiany",
|
||||
@@ -47,21 +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>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultBox({ result }: { result: Result }) {
|
||||
if (!result) return null;
|
||||
const ok = result.status === "APPLIED";
|
||||
@@ -75,23 +67,86 @@ function ResultBox({ result }: { result: Result }) {
|
||||
{result.results.map((x, i) => (
|
||||
<li key={i}>
|
||||
{result.changes?.[i] ? name(result.changes[i].setpoint) : `Zmiana ${i + 1}`}: {LINE_TEXT[x.status]}
|
||||
{x.status === "APPLIED" && x.restoreAt ? `, wróci o ${when(x.restoreAt)}` : ""}
|
||||
{x.status === "APPLIED" && x.restoreAt ? `, wróci ${dateTime(x.restoreAt)}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
} else if (ok && result.text) {
|
||||
body = result.text;
|
||||
} else if (ok) {
|
||||
body = <>Zapisano.{result.restoreAt ? ` Wrócimy do poprzedniej wartości o ${when(result.restoreAt)}.` : ""}</>;
|
||||
body = <>Zapisano.{result.restoreAt ? ` Wrócimy do poprzedniej wartości ${dateTime(result.restoreAt)}.` : ""}</>;
|
||||
} else {
|
||||
body = result.text ?? RESULT_TEXT[result.status];
|
||||
}
|
||||
return <div role={ok ? "status" : "alert"} className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${tone}`}>{body}</div>;
|
||||
}
|
||||
|
||||
function changeSentence(data: Setpoints, c: Change) {
|
||||
const DAY_MS = 24 * 3600_000;
|
||||
|
||||
// Away is held by the gateway until this moment, at most 30 days ahead (ADR-012).
|
||||
// The user may only lower the away temperatures: the gateway refuses a held raise.
|
||||
const lowestAllowed = (s: Setpoint) => Math.min(s.max, s.value! - s.step);
|
||||
|
||||
function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
|
||||
data: Setpoints; preset: Preset; busy: boolean; onCancel: () => void;
|
||||
onConfirm: (until: string, values: Record<string, number>) => void;
|
||||
}) {
|
||||
const limits = Object.fromEntries(preset.changes.map((c) => {
|
||||
const s = find(data, c.setpoint);
|
||||
return [c.setpoint, { min: s.min, max: lowestAllowed(s), step: s.step }];
|
||||
}));
|
||||
const [values, setValues] = useState(() => Object.fromEntries(preset.changes.map((c) => {
|
||||
const { min, max } = limits[c.setpoint];
|
||||
return [c.setpoint, Math.max(min, Math.min(max, c.value))];
|
||||
})));
|
||||
const stuck = preset.changes.some((c) => limits[c.setpoint].max < limits[c.setpoint].min);
|
||||
const stepValue = (id: string, direction: number) => {
|
||||
const { min, max, step } = limits[id];
|
||||
setValues({ ...values, [id]: Math.max(min, Math.min(max, values[id] + direction * step)) });
|
||||
};
|
||||
const [now] = useState(() => Date.now());
|
||||
const min = localInput(new Date(now + 3600_000));
|
||||
const max = localInput(new Date(now + 30 * DAY_MS));
|
||||
const [until, setUntil] = useState(() => {
|
||||
const week = new Date(now + 7 * DAY_MS);
|
||||
week.setHours(18, 0, 0, 0);
|
||||
return localInput(week);
|
||||
});
|
||||
const valid = until >= min && until <= max && !stuck;
|
||||
return (
|
||||
<>
|
||||
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, { ...c, value: values[c.setpoint] }, true)}</p>)}
|
||||
<label className="my-3 block font-semibold">
|
||||
Powrót
|
||||
<input type="datetime-local" value={until} min={min} max={max} onChange={(e) => setUntil(e.target.value)}
|
||||
className="mt-1 block min-h-12 w-full rounded-xl border-2 border-edge bg-panel px-3 text-lg" />
|
||||
</label>
|
||||
{preset.changes.map((c) => {
|
||||
const { min, max } = limits[c.setpoint];
|
||||
const label = name(c.setpoint);
|
||||
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} 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>
|
||||
);
|
||||
})}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={onCancel} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => onConfirm(withOffset(until), values)} disabled={busy || !valid}>Ustaw wyjazd</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function changeSentence(data: Setpoints, c: Change, held: boolean) {
|
||||
const back = degrees(returnValue(data, c.setpoint));
|
||||
const tail = c.hours == null ? "do odwołania" : `przez ${duration(c.hours)}, potem wróci ${back}`;
|
||||
const tail = held ? `do powrotu, potem wróci ${back}`
|
||||
: c.hours == null ? "do odwołania" : `przez ${duration(c.hours)}, potem wróci ${back}`;
|
||||
return <>{name(c.setpoint)}: <b>{degrees(c.value)}</b> {tail}.</>;
|
||||
}
|
||||
|
||||
@@ -103,6 +158,8 @@ export function Settings() {
|
||||
const [presetId, setPresetId] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The setpoint whose pending change is about to be restored, waiting for "Tak, przywróć".
|
||||
const [restoring, setRestoring] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<Result>(null);
|
||||
|
||||
// A refresh can lose the reading while a change is half-made; drop what was built on it.
|
||||
@@ -116,7 +173,7 @@ export function Settings() {
|
||||
const title = <PageTitle title="Ustawienia" subtitle="Temperatury zadane pompy ciepła" />;
|
||||
if (!data) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
|
||||
const cancel = () => { setDraft(null); setPresetId(null); setConfirming(false); setResult(null); };
|
||||
const cancel = () => { setDraft(null); setPresetId(null); setConfirming(false); setRestoring(null); setResult(null); };
|
||||
|
||||
async function send(url: string, payload: unknown, changes?: Change[]) {
|
||||
setBusy(true);
|
||||
@@ -137,6 +194,23 @@ export function Settings() {
|
||||
reload(URL);
|
||||
}
|
||||
|
||||
async function restore(id: string) {
|
||||
setBusy(true);
|
||||
let answer: Result;
|
||||
try {
|
||||
const { outcome } = await postJson<RestoreResult>(`${URL}/${id}/restore`);
|
||||
answer = { status: outcome === "RESTORED" ? "APPLIED" : "FAILED", text: RESTORE_TEXT[outcome] };
|
||||
} catch (error) {
|
||||
const status = error instanceof WriteError ? error.status : 0;
|
||||
answer = { status: "FAILED", text: ERROR_TEXT[status] ?? WRITE_LOST };
|
||||
}
|
||||
setResult(answer);
|
||||
setBusy(false);
|
||||
setRestoring(null);
|
||||
window.scrollTo(0, 0);
|
||||
reload(URL);
|
||||
}
|
||||
|
||||
function step(s: Setpoint, direction: number) {
|
||||
if (unread(s)) return;
|
||||
setResult(null);
|
||||
@@ -153,12 +227,19 @@ export function Settings() {
|
||||
{title}
|
||||
<ResultBox result={result} />
|
||||
<Card title={preset.name} className="max-w-2xl">
|
||||
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c)}</p>)}
|
||||
<p className="my-3">Potwierdzasz?</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={cancel} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => send(`api/presets/${preset.id}`, undefined, preset.changes)} disabled={busy}>Tak, ustaw</Button>
|
||||
</div>
|
||||
{preset.until ? (
|
||||
<ReturnForm data={data} preset={preset} busy={busy} onCancel={cancel}
|
||||
onConfirm={(until, values) => send(`api/presets/${preset.id}`, { until, values }, preset.changes)} />
|
||||
) : (
|
||||
<>
|
||||
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c, false)}</p>)}
|
||||
<p className="my-3">Potwierdzasz?</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={cancel} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => send(`api/presets/${preset.id}`, undefined, preset.changes)} disabled={busy}>Tak, ustaw</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
@@ -175,9 +256,25 @@ export function Settings() {
|
||||
{data.restores.length > 0 && (
|
||||
<Card title="Zmiany czasowe">
|
||||
{data.restores.map((r) => (
|
||||
<p key={r.setpoint} className="my-1">
|
||||
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> do {when(r.due)}, potem <b>{degrees(r.originalValue)}</b>.
|
||||
</p>
|
||||
<div key={r.setpoint} className="border-t border-edge py-3 first:border-t-0 first:pt-0">
|
||||
<p className="my-1">
|
||||
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> {r.due ? `do ${dateTime(r.due)}` : "do odwołania (dopóki serwer działa)"}, potem <b>{degrees(r.originalValue)}</b>.
|
||||
</p>
|
||||
{restoring === r.setpoint ? (
|
||||
<>
|
||||
<p className="my-2 text-lg font-semibold">Przywrócić {degrees(r.originalValue)} teraz?</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={() => setRestoring(null)} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => restore(r.setpoint)} disabled={busy}>Tak, przywróć</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-2 flex">
|
||||
<Button aria-label={`Przywróć teraz: ${name(r.setpoint)}`} disabled={busy}
|
||||
onClick={() => { setResult(null); setRestoring(r.setpoint); }}>Przywróć teraz</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
@@ -201,13 +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>
|
||||
<div className="my-2 flex items-center gap-3">
|
||||
<button aria-label={`Mniej: ${label}`} onClick={() => step(s, -1)} disabled={busy || unread(s) || shown! <= s.min}
|
||||
className="size-16 rounded-xl border-2 border-brand bg-panel text-3xl font-bold text-brand disabled:opacity-40">−</button>
|
||||
<output className="flex-1 text-center text-3xl font-bold tabular-nums">{unread(s) ? "brak odczytu" : degrees(shown)}</output>
|
||||
<button aria-label={`Więcej: ${label}`} onClick={() => step(s, 1)} disabled={busy || unread(s) || shown! >= s.max}
|
||||
className="size-16 rounded-xl border-2 border-brand bg-panel text-3xl font-bold text-brand disabled:opacity-40">+</button>
|
||||
</div>
|
||||
<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,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { Health } from "./types";
|
||||
|
||||
const status = async () => (await import("./Status")).Status;
|
||||
const versions = () => screen.getByText("Wersje").closest("section")!;
|
||||
|
||||
describe("Status", () => {
|
||||
it("shows the version of each component", async () => {
|
||||
serve({ "api/health": example("health") });
|
||||
const Status = await status();
|
||||
render(<Status />);
|
||||
|
||||
expect(await screen.findByText("Wersje")).toBeInTheDocument();
|
||||
expect(versions()).toHaveTextContent(`Aplikacja${__APP_VERSION__}`);
|
||||
expect(versions()).toHaveTextContent("Serwerv1.3.0");
|
||||
expect(versions()).toHaveTextContent("Bramkav1.2.1");
|
||||
});
|
||||
|
||||
it("says unknown for a gateway that does not report its version", async () => {
|
||||
serve({ "api/health": { ...example<Health>("health"), gatewayReachable: false, gatewayVersion: null } });
|
||||
const Status = await status();
|
||||
render(<Status />);
|
||||
|
||||
expect(await screen.findByText("Wersje")).toBeInTheDocument();
|
||||
expect(versions()).toHaveTextContent("Bramkanieznana");
|
||||
expect(versions()).toHaveTextContent("Serwerv1.3.0");
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,11 @@ export function Status() {
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
<Card title="Wersje">
|
||||
<Row label="Aplikacja" value={__APP_VERSION__} />
|
||||
<Row label="Serwer" value={data.backendVersion} />
|
||||
<Row label="Bramka" value={data.gatewayVersion ?? "nieznana"} />
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
+6
-4
@@ -16,10 +16,12 @@ export class WriteError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// A write is sent once and never retried. Resolves with the {status, ...} answer, also for a
|
||||
// refusal (422) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
|
||||
// A write is sent once and never retried. Resolves with the {status, ...} (or {outcome, ...}) answer, also for a
|
||||
// refusal (422), a conflict (409) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
|
||||
// and with status 0 when the network fails or the answer does not come in time.
|
||||
export async function postJson(url: string, body?: unknown): Promise<CommandResult> {
|
||||
export async function postJson<T extends { status?: string; outcome?: string } = CommandResult>(
|
||||
url: string, body?: unknown,
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
// The custom header is what the backend uses to tell this page from another site's form.
|
||||
@@ -30,6 +32,6 @@ export async function postJson(url: string, body?: unknown): Promise<CommandResu
|
||||
throw new WriteError(0);
|
||||
});
|
||||
const answer = await response.json().catch(() => null);
|
||||
if (!answer?.status) throw new WriteError(response.status);
|
||||
if (!answer?.status && !answer?.outcome) throw new WriteError(response.status);
|
||||
return answer;
|
||||
}
|
||||
@@ -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,9 +15,14 @@ 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`);
|
||||
|
||||
// A value in the unit the backend names, with the precision it asks for.
|
||||
export const quantity = (value: number, unit: string, digits: number) =>
|
||||
unit ? `${number(value, digits)} ${unit}` : number(value, digits);
|
||||
|
||||
export function power(watts: Value): string {
|
||||
if (watts == null) return DASH;
|
||||
return Math.abs(watts) >= 1000 ? `${number(watts / 1000, 1)} kW` : `${number(watts)} W`;
|
||||
@@ -64,6 +69,26 @@ export function when(iso: string): string {
|
||||
return sameDay(date, new Date()) ? hour(date) : dayAndHour(date);
|
||||
}
|
||||
|
||||
// "14:05" for today, "2 lis, 18:00" for another day: the end of a trip is a date, not a weekday.
|
||||
export function dateTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (sameDay(date, new Date())) return hour(date);
|
||||
return `${date.toLocaleDateString("pl-PL", { day: "numeric", month: "short", timeZone: ZONE })}, ${hour(date)}`;
|
||||
}
|
||||
|
||||
const two = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
// The value of <input type="datetime-local"> for a moment on the phone's own clock.
|
||||
export const localInput = (date: Date) =>
|
||||
`${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}T${two(date.getHours())}:${two(date.getMinutes())}`;
|
||||
|
||||
// "2026-11-02T18:00" on the phone's clock -> "2026-11-02T18:00:00+01:00", an exact moment for the backend.
|
||||
export function withOffset(local: string): string {
|
||||
const offset = -new Date(local).getTimezoneOffset();
|
||||
const sign = offset < 0 ? "-" : "+";
|
||||
return `${local}:00${sign}${two(Math.floor(Math.abs(offset) / 60))}:${two(Math.abs(offset) % 60)}`;
|
||||
}
|
||||
|
||||
export function duration(hours: number): string {
|
||||
return hours < 1 ? `${Math.round(hours * 60)} min` : `${number(hours, 1).replace(/,0$/, "")} godz.`;
|
||||
}
|
||||
@@ -13,6 +13,9 @@
|
||||
--color-edge: #e2e9e5;
|
||||
--color-brand: #0f8a5f;
|
||||
--color-brand-soft: #e3f4ec;
|
||||
/* A part that runs; red but never the alarm, which is solid (ui.tsx). */
|
||||
--color-run: #c8283f;
|
||||
--color-run-soft: #fde9ec;
|
||||
--color-side: #0e2a21;
|
||||
--color-side-hi: #1b4d3b;
|
||||
--color-hot: #f26b1d;
|
||||
@@ -32,6 +35,8 @@
|
||||
--color-edge: #24352c;
|
||||
--color-brand: #3fd197;
|
||||
--color-brand-soft: #143628;
|
||||
--color-run: #ff7a8a;
|
||||
--color-run-soft: #3d141b;
|
||||
--color-side: #08170f;
|
||||
--color-side-hi: #17402f;
|
||||
--color-off: #4b5a52;
|
||||
|
||||
+96
-1
@@ -20,6 +20,14 @@ export interface HeatPump {
|
||||
setpoints: Record<"coEconomy" | "coComfort" | "cwuEconomy" | "cwuComfort", number | null>;
|
||||
}
|
||||
|
||||
export interface PiSensor {
|
||||
time: string | null;
|
||||
stale: boolean;
|
||||
temperature: number | null;
|
||||
humidity: number | null;
|
||||
pressure: number | null;
|
||||
}
|
||||
|
||||
export interface EnergySource {
|
||||
name: string;
|
||||
time: string | null;
|
||||
@@ -37,6 +45,81 @@ 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;
|
||||
unit: string;
|
||||
digits: number;
|
||||
}
|
||||
|
||||
export interface EnergyDetailsDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
time: string | null;
|
||||
stale: boolean;
|
||||
groups: { title: string; items: EnergyDetailsItem[] }[];
|
||||
}
|
||||
|
||||
export interface EnergyDetails {
|
||||
devices: EnergyDetailsDevice[];
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
t: string;
|
||||
v: number;
|
||||
@@ -51,6 +134,8 @@ export interface History {
|
||||
export interface Health {
|
||||
time: string;
|
||||
gatewayReachable: boolean;
|
||||
backendVersion: string;
|
||||
gatewayVersion: string | null;
|
||||
devices: { name: string; up: boolean; lastSuccess: string | null; lastError: string | null }[];
|
||||
}
|
||||
|
||||
@@ -68,7 +153,8 @@ export interface Restore {
|
||||
register: number;
|
||||
originalValue: number;
|
||||
appliedValue: number;
|
||||
due: string;
|
||||
// null: until changed, which holds only while the backend keeps renewing the lease (ADR-011)
|
||||
due: string | null;
|
||||
}
|
||||
|
||||
export interface Change {
|
||||
@@ -80,6 +166,8 @@ export interface Change {
|
||||
export interface Preset {
|
||||
id: string;
|
||||
name: string;
|
||||
// "chosen": the user picks the end on a calendar and the gateway holds the changes until then (ADR-012)
|
||||
until?: "chosen";
|
||||
changes: Change[];
|
||||
}
|
||||
|
||||
@@ -91,6 +179,13 @@ export interface Setpoints {
|
||||
|
||||
export type LineStatus = "APPLIED" | "REJECTED" | "FAILED" | "UNKNOWN" | "NOT_ATTEMPTED";
|
||||
|
||||
export type RestoreOutcome = "RESTORED" | "NOT_PENDING" | "CONFLICT" | "TOO_SOON" | "FAILED";
|
||||
|
||||
export interface RestoreResult {
|
||||
outcome: RestoreOutcome;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
status: "APPLIED" | "REJECTED" | "FAILED" | "PARTIAL";
|
||||
message?: string;
|
||||
|
||||
+36
-4
@@ -19,18 +19,19 @@ export function Card({ title, aside, className = "", children }: {
|
||||
);
|
||||
}
|
||||
|
||||
type Tone = "ok" | "warn" | "bad" | "off" | "hot";
|
||||
type Tone = "ok" | "run" | "warn" | "bad" | "off";
|
||||
|
||||
const PILL: Record<Tone, string> = {
|
||||
ok: "bg-brand-soft text-brand",
|
||||
warn: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
||||
bad: "bg-red-100 text-alarm dark:bg-red-950 dark:text-red-300",
|
||||
run: "bg-run-soft text-run",
|
||||
// Solid, unlike the soft "run": an alarm must never be mistaken for a part that works.
|
||||
bad: "bg-alarm text-white",
|
||||
off: "bg-edge text-soft",
|
||||
hot: "bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300",
|
||||
};
|
||||
|
||||
const DOT: Record<Tone, string> = {
|
||||
ok: "bg-brand", warn: "bg-amber-500", bad: "bg-alarm", off: "bg-off", hot: "bg-hot",
|
||||
ok: "bg-brand", run: "bg-run", warn: "bg-amber-500", bad: "bg-alarm", off: "bg-off",
|
||||
};
|
||||
|
||||
export function Pill({ tone, live = false, children }: { tone: Tone; live?: boolean; children: ReactNode }) {
|
||||
@@ -107,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>
|
||||
);
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/** The frontend's own release tag, defined at build time in vite.config.ts. */
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -6,6 +6,8 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: "./",
|
||||
// The release tag from the Dockerfile's VERSION build arg; "dev" for a local build.
|
||||
define: { __APP_VERSION__: JSON.stringify(process.env.VERSION ?? "dev") },
|
||||
build: { outDir: "dist", emptyOutDir: true },
|
||||
// `npm run dev` asks a running backend for /api: BACKEND, by default the backend's own default port.
|
||||
server: { proxy: { "/api": process.env.BACKEND ?? "http://localhost:8080" } },
|
||||
|
||||
@@ -8,6 +8,9 @@ COPY src src
|
||||
RUN mvn -B -q package -Dmaven.test.skip=true
|
||||
|
||||
FROM eclipse-temurin:21-jre
|
||||
# The release tag, shown in /health; the release workflow passes it.
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=$VERSION
|
||||
COPY --from=build /src/target/home-pump-1.0.0.jar /app/gateway.jar
|
||||
# Config, spool, restore file and token live on the Pi under the same path (compose.yaml).
|
||||
ENTRYPOINT ["java", "-jar", "/app/gateway.jar"]
|
||||
|
||||
+62
-2
@@ -37,6 +37,9 @@ 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),
|
||||
`restoreCheckSeconds` (30) and `setpoints`: the allowlist `{name, register, min, max, step}`, limited to
|
||||
@@ -52,9 +55,66 @@ Do all of this before setting `commands.enabled` to true:
|
||||
3. Only the backend may reach port 8080 (S6; the gateway has no other access control): set `http.bind` to
|
||||
the WireGuard or LAN address the backend uses, or add a firewall rule on the Pi.
|
||||
4. Traefik must pass the `Host` header unchanged: the backend's CSRF check compares it with `Origin`.
|
||||
5. Confirm the CWU maximum (48 degrees C) in M2 against the controller documentation; the allowlist in
|
||||
`gateway.json` must not exceed it.
|
||||
5. The allowlist in `gateway.json` holds the agreed limits: CWU at most 48 degrees C, CO 5 to 25 (the code
|
||||
ceiling for CWU is 50).
|
||||
|
||||
Then set `commands.enabled` and `docker restart home-pump-gateway`. If the restore file is corrupt at start, the gateway
|
||||
keeps polling and serving, moves the file aside, and `/commands` answers 503 until a restart.
|
||||
|
||||
## 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
|
||||
the earlier of its end time and the end of the lease, through the usual path (conflict check, read-back,
|
||||
backoff). `POST /commands/renew` (same token and throttle as `/commands`, no body) extends the lease of the
|
||||
restores that have not reached their run time and answers `{"renewed": n}`. The backend calls it every
|
||||
5 minutes. A lapsed or failing restore is never revived. Without the backend, or with the tunnel down, a
|
||||
change therefore ends 15 minutes after the last renewal. `restores.json` entries carry `leaseUntil`; one
|
||||
without it (older file) counts as lapsed and is restored on start. `GET /commands` lists both `due`
|
||||
(absent for "until changed") and `leaseUntil`.
|
||||
|
||||
**Held changes (ADR-012).** `POST /commands` with `until` (ISO-8601 with offset, not together with `minutes`)
|
||||
holds the value until then without renewals: the restore has `leaseUntil = due = until`, survives a restart
|
||||
and runs at its end. The gateway accepts it only if the end is in the future and at most 30 days ahead and the
|
||||
new value is lower than both the current value and the original that will come back; otherwise 422 and no
|
||||
write. A renewal only ever extends a lease, so it never shortens a held change. A held change may replace a
|
||||
pending boost (the original stays the first one); a later ordinary change on a held one is a lease again.
|
||||
|
||||
**Restore now.** `POST /commands/restore` with `{"setpoint": "cwu_economy"}` (same token and throttle; the
|
||||
requester comes from `X-Requested-By`) runs that setpoint's pending restore at once through the usual path.
|
||||
200 restored; 404 nothing pending; 409 the register no longer holds our value, so it is left alone and the
|
||||
restore dropped (as a due one); 429 too soon after the last write to it (S4); 502 failed, the restore stays
|
||||
pending with its backoff.
|
||||
|
||||
**Extra poll.** After a write or restore that took, the heat pump is polled once more 3 s later by its own poller thread (so never beside another read, and several writes share one poll), because its derived targets would otherwise show the old value until the next 300 s poll; a failure of it counts like any failed poll.
|
||||
|
||||
**Shown value.** `GET /commands` shows, per setpoint, the value read back after the gateway's last write or
|
||||
restore or the last polled value, whichever is newer: a poll takes minutes, a panel change shows in a later one.
|
||||
|
||||
## Version
|
||||
|
||||
`/health` has a top-level `version`: the release tag the image was built with (`VERSION` build arg and
|
||||
environment variable, set by the release workflow), `dev` for a local build.
|
||||
@@ -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;
|
||||
@@ -48,6 +53,8 @@ import java.util.concurrent.TimeUnit;
|
||||
public final class Gateway implements AutoCloseable {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(Gateway.class);
|
||||
// Long enough for the controller to derive its target values from a new setpoint.
|
||||
private static final Duration REPOLL_DELAY = Duration.ofSeconds(3);
|
||||
private static final Duration WATCHDOG_PERIOD = Duration.ofSeconds(30);
|
||||
// The Pi has no RTC, so before NTP the clock reads an old date; no build can predate this one.
|
||||
// Raise it with a release when convenient; a too-old floor only weakens the guard.
|
||||
@@ -59,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;
|
||||
@@ -98,31 +107,44 @@ 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);
|
||||
Snapshot snapshot = new Snapshot();
|
||||
Poller poller = own(new Poller(devices, snapshot, writer, clock));
|
||||
Poller poller = own(new Poller(devices, snapshot, writer, clock, REPOLL_DELAY));
|
||||
poller.start();
|
||||
Set<String> critical = Set.copyOf(config.watchdog().criticalDevices());
|
||||
Watchdog watchdog = own(new Watchdog(snapshot, clock, critical,
|
||||
config.watchdog().allowedIntervals(), exit));
|
||||
watchdog.start(WATCHDOG_PERIOD);
|
||||
Commands commands = config.commands().enabled() ? startCommands(writer) : null;
|
||||
Commands commands = config.commands().enabled() ? startCommands(writer, poller) : null;
|
||||
// Pushed last, so it closes first: no request arrives while the rest shuts down.
|
||||
GatewayHttpServer http = own(new GatewayHttpServer(snapshot, clock, config.http().address(), critical));
|
||||
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.
|
||||
*/
|
||||
private Commands startCommands(InfluxWriter audit) throws IOException {
|
||||
private Commands startCommands(InfluxWriter audit, Poller poller) throws IOException {
|
||||
var settings = config.commands();
|
||||
var registers = new ModbusSetpointRegisters(heatPumpModbus, config.devices().heatPump().unitId());
|
||||
CommandService service;
|
||||
@@ -130,7 +152,8 @@ public final class Gateway implements AutoCloseable {
|
||||
service = new CommandService(settings.setpoints(), registers,
|
||||
new JsonRestoreStore(settings.restoreFile()), audit, clock, System::nanoTime,
|
||||
EARLIEST_PLAUSIBLE, settings.minInterval(),
|
||||
() -> Thread.sleep(settings.readBackDelayMillis()));
|
||||
() -> Thread.sleep(settings.readBackDelayMillis()),
|
||||
() -> poller.requestPoll(HeatPumpDevice.NAME));
|
||||
} catch (CorruptRestoreFileException e) {
|
||||
LOGGER.error("Commands disabled until restart, pending restores were NOT applied: "
|
||||
+ "restore file moved to {}", e.quarantined(), e);
|
||||
@@ -165,10 +188,15 @@ public final class Gateway implements AutoCloseable {
|
||||
result.add(new HeatPumpDevice(heatPumpModbus, heatPump.settings()));
|
||||
}
|
||||
if (Switchable.isOn(devices.houseMeter())) {
|
||||
result.add(new HouseMeterDevice(modbusOverTcp(devices.houseMeter().connection())));
|
||||
// Its RS485 converter forwards RTU frames as they are, not Modbus TCP.
|
||||
var meterLink = devices.houseMeter().connection();
|
||||
result.add(new HouseMeterDevice(new PduModbusClient(own(TcpTransport.rtuOverTcp(meterLink.settings())))));
|
||||
}
|
||||
if (Switchable.isOn(devices.deye())) {
|
||||
result.add(new DeyeDevice(deyeModbus(devices.deye())));
|
||||
// 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();
|
||||
|
||||
@@ -26,9 +26,21 @@ public final class DeyeDevice implements Device {
|
||||
|
||||
/** One request per block, in this order; the serial (3–7) is identity only and is skipped. */
|
||||
private static final List<Block> BLOCKS = List.of(
|
||||
new Block(514, 2), new Block(520, 2), new Block(526, 1), new Block(529, 1),
|
||||
new Block(586, 3), new Block(590, 1), new Block(598, 3), new Block(622, 4),
|
||||
new Block(650, 4), new Block(672, 2));
|
||||
new Block(500, 1), new Block(514, 22), new Block(540, 2), new Block(586, 6),
|
||||
new Block(598, 3), new Block(609, 1), new Block(622, 4), new Block(636, 1),
|
||||
new Block(650, 4), new Block(672, 8));
|
||||
|
||||
/** A 32-bit counter in /10 kWh, low word first. */
|
||||
private record Counter(String name, int lowRegister) {
|
||||
}
|
||||
|
||||
private static final List<Counter> COUNTERS = List.of(
|
||||
new Counter("battery_charge_total_kwh", 516),
|
||||
new Counter("battery_discharge_total_kwh", 518),
|
||||
new Counter("grid_bought_total_kwh", 522),
|
||||
new Counter("grid_sold_total_kwh", 524),
|
||||
new Counter("load_total_kwh", 527),
|
||||
new Counter("pv_total_kwh", 534));
|
||||
|
||||
private static final List<Field> FIELDS = List.of(
|
||||
new Field("battery_charge_today_kwh", 514, scaled(10)),
|
||||
@@ -53,7 +65,17 @@ public final class DeyeDevice implements Device {
|
||||
new Field("load_power_l3_w", 652, scaled(1)),
|
||||
new Field("load_power_w", 653, scaled(1)),
|
||||
new Field("pv1_power_w", 672, scaled(1)),
|
||||
new Field("pv2_power_w", 673, scaled(1)));
|
||||
new Field("pv2_power_w", 673, scaled(1)),
|
||||
new Field("run_state", 500, scaled(1)),
|
||||
new Field("inverter_temperature_c", 540, x -> (x - 1000) / 10),
|
||||
new Field("heatsink_temperature_c", 541, x -> (x - 1000) / 10),
|
||||
new Field("battery_current_a", 591, x -> signed(x) / 100),
|
||||
new Field("grid_frequency_hz", 609, scaled(100)),
|
||||
new Field("inverter_power_w", 636, DeyeDevice::signed),
|
||||
new Field("pv1_voltage_v", 676, scaled(10)),
|
||||
new Field("pv1_current_a", 677, scaled(10)),
|
||||
new Field("pv2_voltage_v", 678, scaled(10)),
|
||||
new Field("pv2_current_a", 679, scaled(10)));
|
||||
|
||||
private final ModbusClient modbus;
|
||||
|
||||
@@ -78,6 +100,11 @@ public final class DeyeDevice implements Device {
|
||||
for (Field field : FIELDS) {
|
||||
fields.put(field.name(), field.decode().applyAsDouble(words.get(field.register())));
|
||||
}
|
||||
for (Counter counter : COUNTERS) {
|
||||
int low = words.get(counter.lowRegister());
|
||||
long high = words.get(counter.lowRegister() + 1);
|
||||
fields.put(counter.name(), ((high << 16) | low) / 10.0);
|
||||
}
|
||||
return List.of(new Reading("electricity", "deye", Map.of("location", "dom-szczepancowa"),
|
||||
fields, now));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import java.util.Map;
|
||||
/** The Frisko heat pump controller: every poll yields the legacy POMPA point and the full heat_pump point. */
|
||||
public final class HeatPumpDevice implements Device {
|
||||
|
||||
public static final String NAME = "heat-pump";
|
||||
private static final String DATABASE = "Dom_Pompa";
|
||||
|
||||
private final ModbusClient modbus;
|
||||
@@ -26,7 +27,7 @@ public final class HeatPumpDevice implements Device {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "heat-pump";
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,82 +9,78 @@ import home.gateway.application.Snapshot;
|
||||
import home.gateway.domain.Command;
|
||||
import home.gateway.domain.CommandResult;
|
||||
import home.gateway.domain.Restore;
|
||||
import home.gateway.domain.RestoreResult;
|
||||
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;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.Map;
|
||||
|
||||
import static home.gateway.adapters.http.GatewayHttpServer.JSON;
|
||||
import static home.gateway.adapters.http.GatewayHttpServer.respond;
|
||||
|
||||
/**
|
||||
* {@code POST /commands} applies one setpoint change, {@code GET /commands} lists the setpoints
|
||||
* and pending restores. Only the backend may call it (S6): every request needs the shared token.
|
||||
* {@code POST /commands} applies one setpoint change (body: setpoint, value, optional minutes or
|
||||
* until, the end of a held change, ADR-012), {@code GET /commands} lists the setpoints
|
||||
* and pending restores, {@code POST /commands/renew} keeps the live restores alive (ADR-011)
|
||||
* and answers with how many it extended, {@code POST /commands/restore} (body: setpoint) restores one
|
||||
* setpoint now: 200 restored, 404 nothing pending, 409 the register no longer holds our value,
|
||||
* 429 too soon after the last write, 502 failed (the restore stays pending). Only the backend may call it (S6): every request needs the shared token.
|
||||
* HTTP status follows the outcome: 200 applied, 422 rejected, 502 failed on the controller side.
|
||||
* 503 when the write path is unavailable ({@link CommandsUnavailableException}).
|
||||
* Repeated bad tokens lead to 429 for a while ({@link FailedLoginThrottle}).
|
||||
*/
|
||||
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";
|
||||
private static final String RESTORE_PATH = "/commands/restore";
|
||||
|
||||
private record SetpointView(String name, int register, int min, int max, int step, Integer value) {
|
||||
}
|
||||
|
||||
private record Renewal(int renewed) {
|
||||
}
|
||||
|
||||
private record Listing(List<SetpointView> setpoints, List<Restore> restores) {
|
||||
}
|
||||
|
||||
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
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
try (exchange) {
|
||||
var method = exchange.getRequestMethod();
|
||||
if (!exchange.getRequestURI().getPath().equals("/commands")) {
|
||||
var path = exchange.getRequestURI().getPath();
|
||||
if (!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 {
|
||||
dispatch(exchange, method);
|
||||
} else if (guard.admit(exchange)) {
|
||||
dispatch(exchange, method, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatch(HttpExchange exchange, String method) throws IOException {
|
||||
private void dispatch(HttpExchange exchange, String method, String path) throws IOException {
|
||||
try {
|
||||
if (method.equals("GET")) {
|
||||
if (path.equals(RENEW_PATH)) {
|
||||
renew(exchange, method);
|
||||
} else if (path.equals(RESTORE_PATH)) {
|
||||
restoreNow(exchange, method);
|
||||
} else if (method.equals("GET")) {
|
||||
respond(exchange, 200, listing());
|
||||
} else if (method.equals("POST")) {
|
||||
post(exchange);
|
||||
@@ -97,32 +93,43 @@ class CommandsEndpoint implements HttpHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean authorized(HttpExchange exchange) {
|
||||
var header = exchange.getRequestHeaders().getFirst("Authorization");
|
||||
if (header == null || !header.startsWith(BEARER)) {
|
||||
return false;
|
||||
private void renew(HttpExchange exchange, String method) throws IOException {
|
||||
if (method.equals("POST")) {
|
||||
respond(exchange, 200, new Renewal(commands.renewLeases()));
|
||||
} else {
|
||||
exchange.getResponseHeaders().set("Allow", "POST");
|
||||
respond(exchange, 405, new GatewayHttpServer.Error("method not allowed"));
|
||||
}
|
||||
var presented = header.substring(BEARER.length()).getBytes(StandardCharsets.UTF_8);
|
||||
return MessageDigest.isEqual(presented, token);
|
||||
}
|
||||
|
||||
private void restoreNow(HttpExchange exchange, String method) throws IOException {
|
||||
if (!method.equals("POST")) {
|
||||
exchange.getResponseHeaders().set("Allow", "POST");
|
||||
respond(exchange, 405, new GatewayHttpServer.Error("method not allowed"));
|
||||
return;
|
||||
}
|
||||
var body = JsonRequests.readJsonBody(exchange);
|
||||
if (body == null) {
|
||||
return;
|
||||
}
|
||||
var setpoint = body.path("setpoint");
|
||||
if (!setpoint.isTextual()) {
|
||||
respond(exchange, 400, new GatewayHttpServer.Error("bad request: need a text \"setpoint\""));
|
||||
return;
|
||||
}
|
||||
var result = commands.restoreNow(setpoint.textValue(), JsonRequests.requester(exchange));
|
||||
respond(exchange, httpStatus(result.outcome()), result);
|
||||
}
|
||||
|
||||
private void post(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;
|
||||
}
|
||||
var body = readLimited(exchange);
|
||||
var body = JsonRequests.readJsonBody(exchange);
|
||||
if (body == null) {
|
||||
respond(exchange, 413, new GatewayHttpServer.Error("body larger than " + MAX_BODY_BYTES + " bytes"));
|
||||
return;
|
||||
}
|
||||
var requestedBy = exchange.getRequestHeaders().getFirst("X-Requested-By");
|
||||
Command command;
|
||||
try {
|
||||
command = parse(JSON.readTree(body),
|
||||
isPlainName(requestedBy) ? requestedBy : DEFAULT_REQUESTER);
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
command = parse(body, JsonRequests.requester(exchange));
|
||||
} catch (IllegalArgumentException e) {
|
||||
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
|
||||
return;
|
||||
}
|
||||
@@ -130,22 +137,6 @@ class CommandsEndpoint implements HttpHandler {
|
||||
respond(exchange, httpStatus(result.status()), result);
|
||||
}
|
||||
|
||||
/** 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");
|
||||
@@ -157,7 +148,19 @@ class CommandsEndpoint implements HttpHandler {
|
||||
throw new IllegalArgumentException("\"minutes\" must be an integer");
|
||||
}
|
||||
var duration = minutes.isInt() ? Duration.ofMinutes(minutes.intValue()) : null;
|
||||
return new Command(setpoint.textValue(), value.intValue(), duration, requestedBy);
|
||||
return new Command(setpoint.textValue(), value.intValue(), duration, heldUntil(body.path("until")),
|
||||
requestedBy);
|
||||
}
|
||||
|
||||
private static Instant heldUntil(JsonNode until) {
|
||||
if (until.isMissingNode() || until.isNull()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return OffsetDateTime.parse(until.asText()).toInstant();
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new IllegalArgumentException("\"until\" must be an ISO-8601 date and time with offset");
|
||||
}
|
||||
}
|
||||
|
||||
private static int httpStatus(CommandResult.Status status) {
|
||||
@@ -168,24 +171,40 @@ class CommandsEndpoint implements HttpHandler {
|
||||
};
|
||||
}
|
||||
|
||||
private static int httpStatus(RestoreResult.Outcome outcome) {
|
||||
return switch (outcome) {
|
||||
case RESTORED -> 200;
|
||||
case NOT_PENDING -> 404;
|
||||
case CONFLICT -> 409;
|
||||
case TOO_SOON -> 429;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private Listing listing() {
|
||||
var views = commands.setpoints().stream().map(this::view).toList();
|
||||
var verified = commands.verifiedValues();
|
||||
var views = commands.setpoints().stream().map(setpoint -> view(setpoint, verified)).toList();
|
||||
return new Listing(views, commands.pendingRestores());
|
||||
}
|
||||
|
||||
private SetpointView view(Setpoint setpoint) {
|
||||
private SetpointView view(Setpoint setpoint, Map<String, VerifiedValue> verified) {
|
||||
return new SetpointView(setpoint.name(), setpoint.register(), setpoint.min(),
|
||||
setpoint.max(), setpoint.step(), currentValue(setpoint.name()));
|
||||
setpoint.max(), setpoint.step(), currentValue(setpoint.name(), verified.get(setpoint.name())));
|
||||
}
|
||||
|
||||
/** The last polled value, or null while the heat pump has not been read yet. */
|
||||
private Integer currentValue(String setpoint) {
|
||||
return snapshot.states().stream()
|
||||
/**
|
||||
* Whichever is newer: the last poll or the value we read back after our own write (a poll takes
|
||||
* minutes, and a panel change after our write shows up in a later poll). Null while neither exists.
|
||||
*/
|
||||
private Integer currentValue(String setpoint, VerifiedValue verified) {
|
||||
var polled = snapshot.states().stream()
|
||||
.flatMap(state -> state.readings().stream())
|
||||
.filter(reading -> reading.measurement().equals(HEAT_PUMP))
|
||||
.map(reading -> reading.fields().get("setpoint_" + setpoint))
|
||||
.filter(Objects::nonNull)
|
||||
.map(Double::intValue)
|
||||
.findFirst().orElse(null);
|
||||
.filter(reading -> reading.fields().get("setpoint_" + setpoint) != null)
|
||||
.findFirst();
|
||||
if (verified != null && (polled.isEmpty() || verified.at().isAfter(polled.get().time()))) {
|
||||
return verified.value();
|
||||
}
|
||||
return polled.map(reading -> reading.fields().get("setpoint_" + setpoint).intValue()).orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -14,12 +15,13 @@ import java.net.InetSocketAddress;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -28,7 +30,7 @@ public class GatewayHttpServer implements AutoCloseable {
|
||||
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
|
||||
private record Health(String status, List<DeviceHealth> devices) {
|
||||
private record Health(String status, String version, List<DeviceHealth> devices) {
|
||||
}
|
||||
|
||||
record Error(String error) {
|
||||
@@ -39,7 +41,10 @@ public class GatewayHttpServer implements AutoCloseable {
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS);
|
||||
|
||||
public static final int MIN_TOKEN_LENGTH = CommandsEndpoint.MIN_TOKEN_LENGTH;
|
||||
/** 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 = TokenGuard.MIN_TOKEN_LENGTH;
|
||||
|
||||
private final Snapshot snapshot;
|
||||
private final Clock clock;
|
||||
@@ -64,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();
|
||||
}
|
||||
@@ -101,7 +111,7 @@ public class GatewayHttpServer implements AutoCloseable {
|
||||
.allMatch(GatewayHttpServer::isUp);
|
||||
boolean allUp = devices.stream().allMatch(GatewayHttpServer::isUp);
|
||||
var status = !criticalUp ? "DOWN" : allUp ? "UP" : "DEGRADED";
|
||||
respond(exchange, criticalUp ? 200 : 503, new Health(status, devices));
|
||||
respond(exchange, criticalUp ? 200 : 503, new Health(status, VERSION, devices));
|
||||
}
|
||||
|
||||
private static boolean isUp(DeviceHealth device) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import home.gateway.adapters.modbus.ModbusClient;
|
||||
import home.gateway.application.Device;
|
||||
import home.gateway.domain.Reading;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
@@ -14,11 +17,17 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** The Eastron SDM630 house meter: all input registers, one point per poll. */
|
||||
/**
|
||||
* The Eastron SDM630 house meter: all input registers as the legacy PRAD2 point, plus the
|
||||
* per-phase energy counters as a second point of the same poll.
|
||||
*/
|
||||
public final class HouseMeterDevice implements Device {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(HouseMeterDevice.class);
|
||||
private static final Map<String, String> TAGS = Map.of("location", "dom-szczepancowa");
|
||||
private static final int UNIT_ID = 1;
|
||||
private static final int MAX_REGISTERS_PER_REQUEST = 125;
|
||||
private static final Duration INTERVAL = Duration.ofSeconds(60);
|
||||
@@ -29,6 +38,7 @@ public final class HouseMeterDevice implements Device {
|
||||
|
||||
private final ModbusClient modbus;
|
||||
private final List<Block> blocks = blocksOf(Sdm630Registers.INPUT);
|
||||
private final Block phaseEnergyBlock = blocksOf(Sdm630Registers.PHASE_ENERGY).getFirst();
|
||||
|
||||
public HouseMeterDevice(ModbusClient modbus) {
|
||||
this.modbus = modbus;
|
||||
@@ -53,8 +63,25 @@ public final class HouseMeterDevice implements Device {
|
||||
if (fields.isEmpty()) {
|
||||
throw new IOException("house meter returned no finite value");
|
||||
}
|
||||
return List.of(new Reading("electricity", "PRAD2", Map.of("location", "dom-szczepancowa"),
|
||||
fields, now));
|
||||
List<Reading> readings = new ArrayList<>();
|
||||
readings.add(new Reading("electricity", "PRAD2", TAGS, fields, now));
|
||||
readPhaseEnergy(now).ifPresent(readings::add);
|
||||
return readings;
|
||||
}
|
||||
|
||||
/** Extra data must never cost the legacy point: a failed request is logged and skipped. */
|
||||
private Optional<Reading> readPhaseEnergy(Instant now) {
|
||||
Map<String, Double> fields = new LinkedHashMap<>();
|
||||
try {
|
||||
readBlock(phaseEnergyBlock, fields);
|
||||
} catch (IOException e) {
|
||||
log.warn("House meter phase energy not read, PRAD2 is still written", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
if (fields.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new Reading("electricity", "house_meter_phases", TAGS, fields, now));
|
||||
}
|
||||
|
||||
private void readBlock(Block block, Map<String, Double> fields) throws IOException {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package home.gateway.adapters.meter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The SDM630 input registers, as sdm_modbus 0.4.4 reads them in {@code read_all()}. Names are the
|
||||
* field names of {@code electricity.PRAD2}. Every value is a float32 in two registers, unscaled.
|
||||
* The batch number is the library's: registers of one batch are fetched with one request.
|
||||
* The per-phase energy counters below have their own measurement.
|
||||
*/
|
||||
final class Sdm630Registers {
|
||||
|
||||
@@ -88,6 +90,30 @@ final class Sdm630Registers {
|
||||
new Register("total_energy_reactive", 0x0158, 4)
|
||||
);
|
||||
|
||||
private static final int PHASE_ENERGY_FIRST = 0x015a;
|
||||
private static final int PHASES = 3;
|
||||
private static final List<String> PHASE_ENERGY_KINDS = List.of(
|
||||
"import_energy_active_kwh", "export_energy_active_kwh", "total_energy_active_kwh",
|
||||
"import_energy_reactive_kvarh", "export_energy_reactive_kvarh",
|
||||
"total_energy_reactive_kvarh");
|
||||
|
||||
/**
|
||||
* Per-phase energy counters, 0x015A to 0x017D: for each kind in turn the phases L1, L2, L3.
|
||||
* All are fetched with one request.
|
||||
*/
|
||||
static final List<Register> PHASE_ENERGY = phaseEnergy();
|
||||
|
||||
private static List<Register> phaseEnergy() {
|
||||
List<Register> registers = new ArrayList<>();
|
||||
for (String kind : PHASE_ENERGY_KINDS) {
|
||||
for (int phase = 1; phase <= PHASES; phase++) {
|
||||
int address = PHASE_ENERGY_FIRST + WORDS_PER_VALUE * registers.size();
|
||||
registers.add(new Register("l" + phase + "_" + kind, address, 1));
|
||||
}
|
||||
}
|
||||
return List.copyOf(registers);
|
||||
}
|
||||
|
||||
private Sdm630Registers() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.gateway.adapters.modbus;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/** How one request PDU and its response travel over the socket of a {@link TcpTransport}. */
|
||||
interface Framing {
|
||||
|
||||
byte[] frame(int unitId, byte[] pdu);
|
||||
|
||||
/** The response PDU, function code first, for the request framed last. */
|
||||
byte[] readResponse(DataInputStream in, int unitId) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package home.gateway.adapters.modbus;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/** Modbus TCP: an MBAP header with a transaction id before the PDU. */
|
||||
final class MbapFraming implements Framing {
|
||||
|
||||
private static final int HEADER_LENGTH = 7;
|
||||
|
||||
private int transactionId;
|
||||
|
||||
@Override
|
||||
public byte[] frame(int unitId, byte[] pdu) {
|
||||
transactionId = (transactionId + 1) & 0xFFFF;
|
||||
return ByteBuffer.allocate(HEADER_LENGTH + pdu.length)
|
||||
.putShort((short) transactionId)
|
||||
.putShort((short) 0)
|
||||
.putShort((short) (pdu.length + 1))
|
||||
.put((byte) unitId)
|
||||
.put(pdu)
|
||||
.array();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] readResponse(DataInputStream in, int expectedUnitId) throws IOException {
|
||||
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(HEADER_LENGTH));
|
||||
if (header.remaining() < HEADER_LENGTH) {
|
||||
throw new IOException("connection closed by peer");
|
||||
}
|
||||
int id = header.getShort() & 0xFFFF;
|
||||
int protocol = header.getShort() & 0xFFFF;
|
||||
int length = header.getShort() & 0xFFFF;
|
||||
int unitId = header.get() & 0xFF;
|
||||
if (protocol != 0 || id != transactionId || unitId != expectedUnitId || length < 2) {
|
||||
throw new IOException("unexpected MBAP header: transaction %d, protocol %d, length %d, unit %d"
|
||||
.formatted(id, protocol, length, unitId));
|
||||
}
|
||||
byte[] pdu = in.readNBytes(length - 1);
|
||||
if (pdu.length < length - 1) {
|
||||
throw new IOException("connection closed by peer");
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user