Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
378f4f6623 | ||
|
|
82670eb4a1 | ||
|
|
d07be384a1 | ||
|
|
860269a353 | ||
|
|
e4481eeeae | ||
|
|
e1e50eb7c2 | ||
|
|
c7d88e6c0b | ||
|
|
186c1984c0 | ||
|
|
83d9e1ecfc | ||
|
|
cbf507fa18 | ||
|
|
a99f2b677a | ||
|
|
928915d059 | ||
|
|
c0c46973e8 | ||
|
|
64e1e6ffec | ||
|
|
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 |
No files matched your search
+53
-1
@@ -4,6 +4,42 @@ The phone API in Kraków (ADR-008): live state from the gateway, history from In
|
||||
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/stats?range=7d|30d` (example `src/test/resources/api/stats.json`; 400 otherwise, 502 when InfluxDB is down) is the heat pump per local
|
||||
day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` with `date`, `compressorHours`, `compressorStarts`, `cwuHours`, `coHours`,
|
||||
`heaterHours`, `avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
|
||||
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
|
||||
|
||||
`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).
|
||||
|
||||
`GET /api/audit?range=7d` (`24h`, `7d` default, `30d`; 400 otherwise, 502 when InfluxDB fails) is the change history, example
|
||||
`src/test/resources/api/audit.json`: the gateway's audit rows of the heat pump (`Dom_Pompa.setpoint_audit`) and the battery
|
||||
(`electricity.deye_settings_audit`), read only, as `entries` newest first: `time`, `source` (`heat_pump`, `battery`), `what` (Polish label;
|
||||
the Deye registers are named in `DeyeSettingLabels`), `oldValue`/`newValue` (whole numbers, null when not recorded), `unit` (`°C`, `%`,
|
||||
`HHMM`, `0/1`), `who` (`Remote-User` of the request; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
|
||||
|
||||
`GET /api/weather` (example `src/test/resources/api/weather.json`) is the forecast of Open-Meteo for the house and the IMGW warnings that name
|
||||
Krosno (TERYT 1862 or the powiat 1807): `today`, `tomorrow`, `dayAfter` (`date`, `weatherCode` WMO, Polish `description`, `tMax`, `tMin`,
|
||||
`precipitationMm`, `sunshineHours`, `pvForecastKwh`), `now.temperature`, `warnings` (`event`, `level` 1-3, `from`, `to`, `text`; the ones that
|
||||
ended are dropped). `pvFactor` is kWh per MJ/m^2: the Deye plus Fronius energy of the past 14 complete days (from InfluxDB, daily maximum of
|
||||
`pv_today_kwh` and `energy_day_wh`) over the forecast radiation of the same days, so no tilt or azimuth is needed; `pvForecastKwh` is the factor
|
||||
times the day's radiation, and both are null with fewer than 5 days that have both values. The forecast is kept 30 min and the warnings 10 min;
|
||||
when a refresh fails the older answer is served with `stale` true. 503 only when there never was a forecast. The sources are public and need no key.
|
||||
|
||||
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||
without the two URLs.
|
||||
@@ -17,7 +53,9 @@ 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 |
|
||||
| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast |
|
||||
| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count |
|
||||
| `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
|
||||
@@ -26,6 +64,20 @@ 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).
|
||||
|
||||
@@ -4,23 +4,37 @@ import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.adapters.gateway.GatewayHttpClient;
|
||||
import home.backend.adapters.gateway.GatewayHttpCommands;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import home.backend.adapters.influx.InfluxAuditRepository;
|
||||
import home.backend.adapters.influx.InfluxHistoryRepository;
|
||||
import home.backend.adapters.influx.InfluxPvEnergy;
|
||||
import home.backend.adapters.influx.InfluxSettings;
|
||||
import home.backend.adapters.weather.ImgwClient;
|
||||
import home.backend.adapters.weather.OpenMeteoClient;
|
||||
import home.backend.adapters.weather.WeatherSettings;
|
||||
import home.backend.application.AuditService;
|
||||
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.StatsService;
|
||||
import home.backend.application.LeaseRenewer;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.application.WeatherService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Wires the adapters and services together; the only place that knows all of them. */
|
||||
final class Backend {
|
||||
|
||||
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
private Backend() {
|
||||
}
|
||||
|
||||
@@ -31,14 +45,27 @@ final class Backend {
|
||||
*/
|
||||
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
|
||||
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
|
||||
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
|
||||
var setpoints = new SetpointService(commands(settings), PresetFile.load(settings.presetsFile()));
|
||||
var influxSettings = new InfluxSettings(settings.influxUrl(), settings.influxTimeout());
|
||||
var influx = new InfluxHistoryRepository(influxSettings);
|
||||
var commands = commands(settings);
|
||||
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
|
||||
return new ApiServer(settings.port(),
|
||||
new HeatPumpService(gateway, clock),
|
||||
new EnergyService(gateway, clock),
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
|
||||
new HistoryService(influx, clock), setpoints);
|
||||
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, commands, clock),
|
||||
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
|
||||
weather(influxSettings, clock));
|
||||
}
|
||||
|
||||
private static WeatherService weather(InfluxSettings influx, Clock clock) {
|
||||
var place = WeatherSettings.fromEnvironment(System.getenv());
|
||||
return new WeatherService(
|
||||
new OpenMeteoClient(URI.create("https://api.open-meteo.com"), place, WEATHER_TIMEOUT),
|
||||
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WEATHER_TIMEOUT),
|
||||
new InfluxPvEnergy(influx), clock);
|
||||
}
|
||||
|
||||
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
|
||||
|
||||
@@ -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,16 +21,38 @@ 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) {
|
||||
@@ -32,10 +60,14 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
|
||||
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;
|
||||
@@ -44,6 +76,9 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
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();
|
||||
@@ -52,13 +87,21 @@ 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);
|
||||
@@ -70,6 +113,30 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Reserve reserve() throws IOException {
|
||||
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
|
||||
var slots = change.slots().stream().map(slot -> new SlotRequest(slot.slot(),
|
||||
slot.from() == null ? null : slot.from().getHour() * 100 + slot.from().getMinute(),
|
||||
slot.socPct(), slot.gridCharge())).toList();
|
||||
return battery(scheduleEndpoint, new ScheduleRequest(change.enabled(), slots), requestedBy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
|
||||
return battery(reserveEndpoint, new ReserveRequest(pct), requestedBy);
|
||||
}
|
||||
|
||||
private BatteryResult battery(URI uri, Object body, String requestedBy) throws IOException {
|
||||
var request = request(uri).header("Content-Type", "application/json").header("X-Requested-By", requestedBy)
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(JSON.writeValueAsBytes(body)));
|
||||
return JSON.readValue(send(request, BATTERY_VERDICTS), BatteryResult.class);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request() throws IOException {
|
||||
return request(endpoint);
|
||||
}
|
||||
@@ -83,14 +150,15 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
|
||||
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
|
||||
try {
|
||||
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
|
||||
var built = request.build();
|
||||
var response = http.send(built, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (!accepted.contains(response.statusCode())) {
|
||||
throw new IOException("gateway /commands answered " + response.statusCode());
|
||||
throw new IOException("gateway " + built.uri().getPath() + " answered " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted while calling gateway /commands", e);
|
||||
throw new IOException("interrupted while calling the gateway", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,26 @@ 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.AuditService;
|
||||
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.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.application.WeatherService;
|
||||
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;
|
||||
@@ -38,12 +48,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";
|
||||
@@ -61,7 +89,9 @@ public class ApiServer {
|
||||
/** Binds the port now; 0 picks a free one. */
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
HistoryService history, SetpointService setpoints) throws IOException {
|
||||
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
|
||||
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather)
|
||||
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).
|
||||
@@ -73,13 +103,33 @@ 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/weather", query -> weather.current());
|
||||
api("/api/health", query -> health.current());
|
||||
route("/api/history", exchange -> handleHistory(exchange, history));
|
||||
route("/api/audit", exchange -> handleAudit(exchange, audit));
|
||||
route("/api/stats", exchange -> handleStats(exchange, stats));
|
||||
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. */
|
||||
@@ -178,15 +228,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));
|
||||
}
|
||||
@@ -200,6 +298,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;
|
||||
@@ -226,6 +344,31 @@ public class ApiServer {
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleAudit(HttpExchange exchange, AuditService audit) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
|
||||
try {
|
||||
send(exchange, 200, audit.audit(query.get("range")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("audit unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleStats(HttpExchange exchange, StatsService stats) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
try {
|
||||
send(exchange, 200, stats.stats(parseQuery(exchange.getRequestURI().getRawQuery()).get("range")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("stats unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private interface Action {
|
||||
void run() throws IOException;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.AuditRepository;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The gateway's audit measurements (S3) read from InfluxDB; never written. */
|
||||
public final class InfluxAuditRepository implements AuditRepository {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxAuditRepository(InfluxSettings settings) {
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) throws IOException {
|
||||
return rows("Dom_Pompa", "setpoint_audit", "setpoint", from, to);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) throws IOException {
|
||||
return rows("electricity", "deye_settings_audit", "register", from, to);
|
||||
}
|
||||
|
||||
private List<Row> rows(String database, String measurement, String targetTag, Instant from, Instant to)
|
||||
throws IOException {
|
||||
String query = "SELECT * FROM \"" + measurement + "\" WHERE time >= " + from.toEpochMilli()
|
||||
+ "ms AND time < " + to.toEpochMilli() + "ms ORDER BY time DESC";
|
||||
var rows = new ArrayList<Row>();
|
||||
for (JsonNode result : results(influx.query(database, query))) {
|
||||
for (JsonNode series : result.path("series")) {
|
||||
for (JsonNode values : series.path("values")) {
|
||||
rows.add(row(series.path("columns"), values, targetTag));
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static JsonNode results(String body) throws IOException {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = JSON.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
}
|
||||
failOnError(root);
|
||||
for (JsonNode result : root.path("results")) {
|
||||
failOnError(result);
|
||||
}
|
||||
return root.path("results");
|
||||
}
|
||||
|
||||
private static void failOnError(JsonNode node) throws IOException {
|
||||
if (node.hasNonNull("error")) {
|
||||
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||
}
|
||||
}
|
||||
|
||||
private static Row row(JsonNode columns, JsonNode values, String targetTag) {
|
||||
Map<String, JsonNode> byName = new HashMap<>();
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
byName.put(columns.get(i).asText(), values.get(i));
|
||||
}
|
||||
return new Row(Instant.ofEpochMilli(byName.get("time").asLong()), text(byName.get(targetTag)),
|
||||
text(byName.get("requested_by")), text(byName.get("status")),
|
||||
number(byName.get("old_value")), number(byName.get("new_value")));
|
||||
}
|
||||
|
||||
private static String text(JsonNode node) {
|
||||
return node == null || node.isNull() ? "" : node.asText();
|
||||
}
|
||||
|
||||
private static Double number(JsonNode node) {
|
||||
return node == null || !node.isNumber() ? null : node.asDouble();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Read-only queries to InfluxDB 1.x over its HTTP {@code /query} endpoint; times come back in epoch ms. */
|
||||
final class InfluxClient {
|
||||
|
||||
private final InfluxSettings settings;
|
||||
private final HttpClient http;
|
||||
|
||||
InfluxClient(InfluxSettings settings) {
|
||||
this.settings = settings;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
||||
}
|
||||
|
||||
String query(String database, String query) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
|
||||
.timeout(settings.requestTimeout())
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private URI queryUri(String database, String query) {
|
||||
String base = settings.baseUri().toString().replaceAll("/+$", "");
|
||||
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import home.backend.application.HeatPumpSamples;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
@@ -20,14 +16,12 @@ import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
|
||||
public final class InfluxHistoryRepository implements HistoryRepository {
|
||||
public final class InfluxHistoryRepository implements HistoryRepository, HeatPumpSamples {
|
||||
|
||||
private final InfluxSettings settings;
|
||||
private final HttpClient http;
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxHistoryRepository(InfluxSettings settings) {
|
||||
this.settings = settings;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -57,39 +51,19 @@ public final class InfluxHistoryRepository implements HistoryRepository {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HeatPumpSample> samples(Instant from, Instant to) throws IOException {
|
||||
String query = InfluxSamples.query(from.toEpochMilli(), to.toEpochMilli());
|
||||
return InfluxSamples.parse(influx.query(SeriesSources.LEGACY, query));
|
||||
}
|
||||
|
||||
private Map<Long, Double> part(Source source, Instant from, Instant to, long stepMinutes) throws IOException {
|
||||
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
|
||||
return InfluxResponse.parse(fetch(source.database(), query));
|
||||
return InfluxResponse.parse(influx.query(source.database(), query));
|
||||
}
|
||||
|
||||
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
|
||||
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
|
||||
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
|
||||
}
|
||||
|
||||
private String fetch(String database, String query) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
|
||||
.timeout(settings.requestTimeout())
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private URI queryUri(String database, String query) {
|
||||
String base = settings.baseUri().toString().replaceAll("/+$", "");
|
||||
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import home.backend.application.PvEnergyRepository;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** Daily PV energy: the Deye's and the Fronius's own day counters, each read as the day's maximum. */
|
||||
public final class InfluxPvEnergy implements PvEnergyRepository {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||
private static final String DATABASE = "electricity";
|
||||
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxPvEnergy(InfluxSettings settings) {
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException {
|
||||
Map<LocalDate, Double> deye = dailyMax("deye", "pv_today_kwh", from, to);
|
||||
Map<LocalDate, Double> fronius = dailyMax("fronius", "energy_day_wh", from, to);
|
||||
Map<LocalDate, Double> total = new TreeMap<>();
|
||||
deye.forEach((day, kwh) -> {
|
||||
Double wh = fronius.get(day);
|
||||
if (wh != null) {
|
||||
total.put(day, kwh + wh / 1000);
|
||||
}
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
private Map<LocalDate, Double> dailyMax(String measurement, String field, LocalDate from, LocalDate to)
|
||||
throws IOException {
|
||||
String query = "SELECT max(\"" + field + "\") FROM \"" + measurement + "\" WHERE time >= "
|
||||
+ millis(from) + "ms AND time < " + millis(to) + "ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')";
|
||||
Map<LocalDate, Double> days = new TreeMap<>();
|
||||
InfluxResponse.parse(influx.query(DATABASE, query)).forEach((start, max) ->
|
||||
days.put(Instant.ofEpochMilli(start).atZone(ZONE).toLocalDate(), max));
|
||||
return days;
|
||||
}
|
||||
|
||||
private static long millis(LocalDate day) {
|
||||
return day.atStartOfDay(ZONE).toInstant().toEpochMilli();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** The raw {@code heat_pump} rows of an InfluxDB 1.x query. */
|
||||
final class InfluxSamples {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private InfluxSamples() {
|
||||
}
|
||||
|
||||
static String query(long fromMs, long toMs) {
|
||||
return "SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
|
||||
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
|
||||
}
|
||||
|
||||
/** The rows of {@link #query}, as returned (ascending); empty when there is no data. */
|
||||
static List<HeatPumpSample> parse(String body) throws IOException {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = JSON.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
}
|
||||
failOnError(root);
|
||||
List<HeatPumpSample> samples = new ArrayList<>();
|
||||
for (JsonNode result : root.path("results")) {
|
||||
failOnError(result);
|
||||
for (JsonNode series : result.path("series")) {
|
||||
for (JsonNode row : series.path("values")) {
|
||||
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
|
||||
number(row, 1), number(row, 2), number(row, 3), number(row, 4),
|
||||
number(row, 5), number(row, 6)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
private static Double number(JsonNode row, int column) {
|
||||
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
|
||||
}
|
||||
|
||||
private static void failOnError(JsonNode node) throws IOException {
|
||||
if (node.hasNonNull("error")) {
|
||||
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import java.util.Map;
|
||||
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
|
||||
final class SeriesSources {
|
||||
|
||||
private static final String LEGACY = "Dom_Pompa";
|
||||
static final String LEGACY = "Dom_Pompa";
|
||||
private static final String ELECTRICITY = "electricity";
|
||||
|
||||
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
|
||||
/** GET of a public JSON document, with a timeout on connecting and on the whole request. */
|
||||
final class HttpJson {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private final HttpClient http;
|
||||
private final Duration timeout;
|
||||
|
||||
HttpJson(Duration timeout) {
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
JsonNode get(URI uri) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(uri).timeout(timeout).GET().build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException(uri.getHost() + " answered HTTP " + response.statusCode());
|
||||
}
|
||||
return JSON.readTree(response.body());
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException(uri.getHost() + " returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("request to " + uri.getHost() + " interrupted", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import home.backend.application.WarningSource;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* IMGW's public list of meteorological warnings, kept to those naming one of our TERYT areas. A warning
|
||||
* that is malformed (no degree or times) is left out rather than failing the others.
|
||||
*/
|
||||
public final class ImgwClient implements WarningSource {
|
||||
|
||||
private static final ZoneId POLAND = ZoneId.of("Europe/Warsaw");
|
||||
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final HttpJson http;
|
||||
private final URI uri;
|
||||
private final List<String> teryt;
|
||||
|
||||
public ImgwClient(URI baseUri, WeatherSettings place, Duration timeout) {
|
||||
this.http = new HttpJson(timeout);
|
||||
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/api/data/warningsmeteo");
|
||||
this.teryt = place.teryt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Warning> warnings() throws IOException {
|
||||
JsonNode root = http.get(uri);
|
||||
if (!root.isArray()) {
|
||||
throw new IOException("IMGW returned no list of warnings");
|
||||
}
|
||||
List<Warning> ours = new ArrayList<>();
|
||||
for (JsonNode item : root) {
|
||||
if (covers(item.path("teryt"))) {
|
||||
warning(item).ifPresent(ours::add);
|
||||
}
|
||||
}
|
||||
return ours;
|
||||
}
|
||||
|
||||
private boolean covers(JsonNode codes) {
|
||||
for (JsonNode code : codes) {
|
||||
if (teryt.contains(code.asText())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Optional<Warning> warning(JsonNode item) {
|
||||
try {
|
||||
return Optional.of(new Warning(item.path("nazwa_zdarzenia").asText(),
|
||||
Integer.parseInt(item.path("stopien").asText()),
|
||||
time(item.path("obowiazuje_od")), time(item.path("obowiazuje_do")),
|
||||
item.path("tresc").asText()));
|
||||
} catch (NumberFormatException | DateTimeException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static OffsetDateTime time(JsonNode text) {
|
||||
return LocalDateTime.parse(text.asText(), TIME).atZone(POLAND).toOffsetDateTime();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import home.backend.application.ForecastSource;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.Forecast.DailyForecast;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 3, and the temperature now. */
|
||||
public final class OpenMeteoClient implements ForecastSource {
|
||||
|
||||
private static final String DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,"
|
||||
+ "shortwave_radiation_sum,sunshine_duration";
|
||||
|
||||
private final HttpJson http;
|
||||
private final URI uri;
|
||||
|
||||
public OpenMeteoClient(URI baseUri, WeatherSettings place, Duration timeout) {
|
||||
this.http = new HttpJson(timeout);
|
||||
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/v1/forecast?latitude="
|
||||
+ place.latitude() + "&longitude=" + place.longitude() + "&daily=" + DAILY
|
||||
+ "¤t=temperature_2m&timezone=Europe%2FWarsaw&past_days=14&forecast_days=3");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Forecast forecast() throws IOException {
|
||||
JsonNode root = http.get(uri);
|
||||
JsonNode daily = root.path("daily");
|
||||
JsonNode dates = daily.path("time");
|
||||
if (!dates.isArray() || dates.isEmpty()) {
|
||||
throw new IOException("Open-Meteo returned no daily forecast");
|
||||
}
|
||||
Map<LocalDate, DailyForecast> days = new TreeMap<>();
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
days.put(date(dates.get(i).asText()), new DailyForecast(
|
||||
integer(daily, "weather_code", i), number(daily, "temperature_2m_max", i),
|
||||
number(daily, "temperature_2m_min", i), number(daily, "precipitation_sum", i),
|
||||
number(daily, "shortwave_radiation_sum", i), number(daily, "sunshine_duration", i)));
|
||||
}
|
||||
JsonNode now = root.path("current").path("temperature_2m");
|
||||
return new Forecast(now.isNumber() ? now.asDouble() : null, days);
|
||||
}
|
||||
|
||||
private static LocalDate date(String text) throws IOException {
|
||||
try {
|
||||
return LocalDate.parse(text);
|
||||
} catch (DateTimeException e) {
|
||||
throw new IOException("Open-Meteo returned a bad date: " + text, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Double number(JsonNode daily, String name, int i) {
|
||||
JsonNode value = daily.path(name).path(i);
|
||||
return value.isNumber() ? value.asDouble() : null;
|
||||
}
|
||||
|
||||
private static Integer integer(JsonNode daily, String name, int i) {
|
||||
JsonNode value = daily.path(name).path(i);
|
||||
return value.isNumber() ? value.asInt() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Where the house is and which IMGW areas (TERYT powiat codes) count as ours. Krosno is a city with
|
||||
* powiat rights, 1862; 1807 is the surrounding powiat krośnieński, which warnings may name instead.
|
||||
*/
|
||||
public record WeatherSettings(double latitude, double longitude, List<String> teryt) {
|
||||
|
||||
public static WeatherSettings fromEnvironment(Map<String, String> env) {
|
||||
return new WeatherSettings(
|
||||
coordinate(env, "WEATHER_LAT", 49.684, 90),
|
||||
coordinate(env, "WEATHER_LON", 21.770, 180),
|
||||
List.of(env.getOrDefault("WEATHER_TERYT", "1862,1807").strip().split("\\s*,\\s*")));
|
||||
}
|
||||
|
||||
private static double coordinate(Map<String, String> env, String name, double fallback, double limit) {
|
||||
String value = env.get(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
double number = Double.parseDouble(value.strip());
|
||||
if (Math.abs(number) <= limit) {
|
||||
return number;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// reported below with the same message
|
||||
}
|
||||
throw new IllegalArgumentException(name + " must be a number from -" + limit + " to " + limit
|
||||
+ ", got: " + value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** The gateway's audit rows in InfluxDB (S3), read only. */
|
||||
public interface AuditRepository {
|
||||
|
||||
/** One audited write as recorded; {@code target} is the setpoint name or the Deye register. */
|
||||
record Row(Instant time, String target, String requestedBy, String status, Double oldValue, Double newValue) {
|
||||
}
|
||||
|
||||
List<Row> heatPump(Instant from, Instant to) throws IOException;
|
||||
|
||||
List<Row> battery(Instant from, Instant to) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.AuditRepository.Row;
|
||||
import home.backend.domain.AuditEntry;
|
||||
import home.backend.domain.AuditView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** S3: who changed what and when, from the gateway's audit rows of the heat pump and of the battery. */
|
||||
public class AuditService {
|
||||
|
||||
private static final String DEFAULT_RANGE = "7d";
|
||||
private static final Map<String, Duration> RANGES =
|
||||
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7), "30d", Duration.ofDays(30));
|
||||
private static final Map<String, String> SETPOINTS = Map.of(
|
||||
"cwu_economy", "CWU eko", "cwu_comfort", "CWU komfort",
|
||||
"co_economy", "CO eko", "co_comfort", "CO komfort");
|
||||
private static final String RESTORE_USER = "restore";
|
||||
private static final String RESTORE_LABEL = "automatycznie (przywrócenie)";
|
||||
|
||||
private final AuditRepository repository;
|
||||
private final Clock clock;
|
||||
|
||||
public AuditService(AuditRepository repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param range {@code 24h}, {@code 7d} or {@code 30d}; null means 7d
|
||||
* @throws IllegalArgumentException for an unknown range
|
||||
*/
|
||||
public AuditView audit(String range) throws IOException {
|
||||
Duration length = RANGES.get(range == null ? DEFAULT_RANGE : range);
|
||||
if (length == null) {
|
||||
throw new IllegalArgumentException("unknown range: " + range);
|
||||
}
|
||||
Instant to = clock.instant();
|
||||
Instant from = to.minus(length);
|
||||
var heatPump = repository.heatPump(from, to).stream().map(AuditService::heatPumpEntry);
|
||||
var battery = repository.battery(from, to).stream().map(AuditService::batteryEntry);
|
||||
return new AuditView(Stream.concat(heatPump, battery)
|
||||
.sorted(Comparator.comparing(AuditEntry::time).reversed())
|
||||
.toList());
|
||||
}
|
||||
|
||||
private static AuditEntry heatPumpEntry(Row row) {
|
||||
return entry(AuditEntry.HEAT_PUMP, row, SETPOINTS.getOrDefault(row.target(), row.target()), "°C");
|
||||
}
|
||||
|
||||
private static AuditEntry batteryEntry(Row row) {
|
||||
var label = DeyeSettingLabels.of(row.target());
|
||||
return entry(AuditEntry.BATTERY, row, label.what(), label.unit());
|
||||
}
|
||||
|
||||
private static AuditEntry entry(String source, Row row, String what, String unit) {
|
||||
return new AuditEntry(row.time(), source, what, whole(row.oldValue()), whole(row.newValue()), unit,
|
||||
RESTORE_USER.equals(row.requestedBy()) ? RESTORE_LABEL : row.requestedBy(), row.status());
|
||||
}
|
||||
|
||||
private static Integer whole(Double value) {
|
||||
return value == null ? null : (int) Math.round(value);
|
||||
}
|
||||
}
|
||||
@@ -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,47 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/** One remote answer kept for a while; when a refresh fails the older answer is served and marked stale. */
|
||||
final class Cached<T> {
|
||||
|
||||
interface Fetch<T> {
|
||||
T fetch() throws IOException;
|
||||
}
|
||||
|
||||
record Entry<T>(T value, boolean stale) {
|
||||
}
|
||||
|
||||
private final Duration ttl;
|
||||
private final Clock clock;
|
||||
private final Fetch<T> source;
|
||||
private T value;
|
||||
private Instant fetchedAt;
|
||||
|
||||
Cached(Duration ttl, Clock clock, Fetch<T> source) {
|
||||
this.ttl = ttl;
|
||||
this.clock = clock;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/** @throws IOException when the refresh fails and there is nothing older to serve */
|
||||
synchronized Entry<T> get() throws IOException {
|
||||
Instant now = clock.instant();
|
||||
if (value != null && now.isBefore(fetchedAt.plus(ttl))) {
|
||||
return new Entry<>(value, false);
|
||||
}
|
||||
try {
|
||||
value = source.fetch();
|
||||
fetchedAt = now;
|
||||
return new Entry<>(value, false);
|
||||
} catch (IOException e) {
|
||||
if (value == null) {
|
||||
throw e;
|
||||
}
|
||||
return new Entry<>(value, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.StatsView.Day;
|
||||
import home.backend.domain.StatsView.Totals;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** What the heat pump did over some span of polls; days are added up into the totals. */
|
||||
final class DayTally {
|
||||
|
||||
private double compressorSeconds;
|
||||
private double cwuSeconds;
|
||||
private double coSeconds;
|
||||
private double heaterSeconds;
|
||||
private int starts;
|
||||
private int samples;
|
||||
private double outdoorSum;
|
||||
private int outdoorCount;
|
||||
private Double minCwu;
|
||||
private Double maxCwu;
|
||||
|
||||
void sample(HeatPumpSample s) {
|
||||
samples++;
|
||||
if (s.outdoorTemp() != null) {
|
||||
outdoorSum += s.outdoorTemp();
|
||||
outdoorCount++;
|
||||
}
|
||||
if (s.cwuTemp() != null) {
|
||||
minCwu = lower(minCwu, s.cwuTemp());
|
||||
maxCwu = higher(maxCwu, s.cwuTemp());
|
||||
}
|
||||
}
|
||||
|
||||
/** The outputs of {@code s} held for {@code held}. */
|
||||
void hold(HeatPumpSample s, Duration held) {
|
||||
double seconds = held.toMillis() / 1000.0;
|
||||
compressorSeconds += on(s.compressor()) ? seconds : 0;
|
||||
cwuSeconds += on(s.cwu()) ? seconds : 0;
|
||||
coSeconds += on(s.co()) ? seconds : 0;
|
||||
heaterSeconds += on(s.heater()) ? seconds : 0;
|
||||
}
|
||||
|
||||
void start() {
|
||||
starts++;
|
||||
}
|
||||
|
||||
void add(DayTally other) {
|
||||
compressorSeconds += other.compressorSeconds;
|
||||
cwuSeconds += other.cwuSeconds;
|
||||
coSeconds += other.coSeconds;
|
||||
heaterSeconds += other.heaterSeconds;
|
||||
starts += other.starts;
|
||||
samples += other.samples;
|
||||
outdoorSum += other.outdoorSum;
|
||||
outdoorCount += other.outdoorCount;
|
||||
if (other.minCwu != null) {
|
||||
minCwu = lower(minCwu, other.minCwu);
|
||||
maxCwu = higher(maxCwu, other.maxCwu);
|
||||
}
|
||||
}
|
||||
|
||||
Day day(LocalDate date) {
|
||||
if (samples == 0) {
|
||||
return new Day(date, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
return new Day(date, hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
||||
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
|
||||
}
|
||||
|
||||
Totals totals() {
|
||||
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
||||
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
|
||||
}
|
||||
|
||||
static boolean on(Double output) {
|
||||
return output != null && output > 0;
|
||||
}
|
||||
|
||||
private static Double lower(Double known, double value) {
|
||||
return known == null ? value : Math.min(known, value);
|
||||
}
|
||||
|
||||
private static Double higher(Double known, double value) {
|
||||
return known == null ? value : Math.max(known, value);
|
||||
}
|
||||
|
||||
private Double avgOutdoor() {
|
||||
return outdoorCount == 0 ? null : Math.round(outdoorSum / outdoorCount * 10) / 10.0;
|
||||
}
|
||||
|
||||
private static double hours(double seconds) {
|
||||
return Math.round(seconds / 3600 * 100) / 100.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/** Polish name and unit of the Deye program registers and of the app's reserve, as the gateway audits them. */
|
||||
final class DeyeSettingLabels {
|
||||
|
||||
record Label(String what, String unit) {
|
||||
}
|
||||
|
||||
private static final int TOU_ENABLE = 146;
|
||||
private static final int FIRST_START = 148;
|
||||
private static final int FIRST_SOC = 166;
|
||||
private static final int FIRST_GRID_CHARGE = 172;
|
||||
private static final int SLOTS = 6;
|
||||
|
||||
private DeyeSettingLabels() {
|
||||
}
|
||||
|
||||
static Label of(String register) {
|
||||
if ("reserve".equals(register)) {
|
||||
return new Label("Rezerwa baterii", "%");
|
||||
}
|
||||
int address;
|
||||
try {
|
||||
address = Integer.parseInt(register);
|
||||
} catch (NumberFormatException e) {
|
||||
return unknown(register);
|
||||
}
|
||||
if (address == TOU_ENABLE) {
|
||||
return new Label("Harmonogram baterii", "0/1");
|
||||
}
|
||||
return slot(address, FIRST_START, "start", "HHMM")
|
||||
.or(() -> slot(address, FIRST_SOC, "poziom", "%"))
|
||||
.or(() -> slot(address, FIRST_GRID_CHARGE, "ładowanie z sieci", "0/1"))
|
||||
.orElseGet(() -> unknown(register));
|
||||
}
|
||||
|
||||
private static Label unknown(String register) {
|
||||
return new Label("Rejestr " + register, "");
|
||||
}
|
||||
|
||||
private static Optional<Label> slot(int address, int first, String what, String unit) {
|
||||
return address >= first && address < first + SLOTS
|
||||
? Optional.of(new Label("Przedział " + (address - first + 1) + ": " + what, unit))
|
||||
: Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Forecast;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** The weather forecast service: the past 14 days and the next 3. */
|
||||
public interface ForecastSource {
|
||||
|
||||
Forecast forecast() throws IOException;
|
||||
}
|
||||
@@ -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,6 +13,9 @@ 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. */
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** The heat pump polls stored in InfluxDB, oldest first. */
|
||||
public interface HeatPumpSamples {
|
||||
|
||||
List<HeatPumpSample> samples(Instant from, Instant to) throws IOException;
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/** What the panels produced, from InfluxDB. */
|
||||
public interface PvEnergyRepository {
|
||||
|
||||
/** Deye plus Fronius kWh per local day from {@code from} up to {@code to}; days missing either are absent. */
|
||||
Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,13 @@ final class SnapshotFields {
|
||||
|
||||
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
|
||||
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
|
||||
return stale(device, reading, now, MAX_AGE);
|
||||
}
|
||||
|
||||
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now,
|
||||
Duration maxAge) {
|
||||
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|
||||
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 0;
|
||||
|| Duration.between(reading.get().time(), now).compareTo(maxAge) > 0;
|
||||
}
|
||||
|
||||
/** One device's fields; empty when it is stale, so nothing old is shown as current. */
|
||||
@@ -35,8 +40,12 @@ final class SnapshotFields {
|
||||
}
|
||||
|
||||
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now) {
|
||||
return live(snapshot, device, measurement, now, MAX_AGE);
|
||||
}
|
||||
|
||||
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now, Duration maxAge) {
|
||||
Optional<Measurement> reading = find(snapshot, device, measurement);
|
||||
boolean stale = stale(device(snapshot, device), reading, now);
|
||||
boolean stale = stale(device(snapshot, device), reading, now, maxAge);
|
||||
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
|
||||
return new Live(source, stale ? Map.of() : reading.get().fields());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.StatsView;
|
||||
import home.backend.domain.StatsView.Day;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* F2: the heat pump's work per local day over the last 7 or 30 days. A poll is taken to hold its
|
||||
* outputs until the next poll, but at most {@link #MAX_HOLD}, so a gap in the data adds no hours.
|
||||
*/
|
||||
public class StatsService {
|
||||
|
||||
static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||
static final Duration MAX_HOLD = Duration.ofSeconds(600);
|
||||
private static final Map<String, Integer> RANGE_DAYS = Map.of("7d", 7, "30d", 30);
|
||||
|
||||
private final HeatPumpSamples repository;
|
||||
private final Clock clock;
|
||||
|
||||
public StatsService(HeatPumpSamples repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param range {@code 7d} or {@code 30d}
|
||||
* @throws IllegalArgumentException for an unknown range
|
||||
*/
|
||||
public StatsView stats(String range) throws IOException {
|
||||
Integer length = range == null ? null : RANGE_DAYS.get(range);
|
||||
if (length == null) {
|
||||
throw new IllegalArgumentException("unknown range: " + range);
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
LocalDate first = now.atZone(ZONE).toLocalDate().minusDays(length - 1);
|
||||
Map<LocalDate, DayTally> tallies = new TreeMap<>();
|
||||
for (int i = 0; i < length; i++) {
|
||||
tallies.put(first.plusDays(i), new DayTally());
|
||||
}
|
||||
tally(repository.samples(first.atStartOfDay(ZONE).toInstant(), now), now, tallies);
|
||||
|
||||
DayTally all = new DayTally();
|
||||
List<Day> days = new ArrayList<>();
|
||||
tallies.forEach((date, tally) -> {
|
||||
days.add(tally.day(date));
|
||||
all.add(tally);
|
||||
});
|
||||
return new StatsView(range, days, all.totals());
|
||||
}
|
||||
|
||||
private static void tally(List<HeatPumpSample> samples, Instant now, Map<LocalDate, DayTally> tallies) {
|
||||
HeatPumpSample previous = null;
|
||||
for (int i = 0; i < samples.size(); i++) {
|
||||
HeatPumpSample s = samples.get(i);
|
||||
DayTally day = tallies.get(s.t().atZone(ZONE).toLocalDate());
|
||||
day.sample(s);
|
||||
if (previous != null && previous.compressor() != null && previous.compressor() == 0
|
||||
&& DayTally.on(s.compressor())) {
|
||||
day.start();
|
||||
}
|
||||
previous = s;
|
||||
hold(s, i + 1 < samples.size() ? samples.get(i + 1).t() : now, tallies);
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds the time {@code s} was in force to the days it falls in; a stretch over midnight is split. */
|
||||
private static void hold(HeatPumpSample s, Instant next, Map<LocalDate, DayTally> tallies) {
|
||||
Instant end = Duration.between(s.t(), next).compareTo(MAX_HOLD) > 0 ? s.t().plus(MAX_HOLD) : next;
|
||||
Instant at = s.t();
|
||||
while (at.isBefore(end)) {
|
||||
LocalDate date = at.atZone(ZONE).toLocalDate();
|
||||
Instant midnight = date.plusDays(1).atStartOfDay(ZONE).toInstant();
|
||||
Instant stop = midnight.isBefore(end) ? midnight : end;
|
||||
tallies.get(date).hold(s, Duration.between(at, stop));
|
||||
at = stop;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/** Weather warnings that cover the house, whether in force now or announced. */
|
||||
public interface WarningSource {
|
||||
|
||||
List<Warning> warnings() throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.Cached.Entry;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.Forecast.DailyForecast;
|
||||
import home.backend.domain.PvCalibration;
|
||||
import home.backend.domain.WeatherCodes;
|
||||
import home.backend.domain.WeatherView;
|
||||
import home.backend.domain.WeatherView.Day;
|
||||
import home.backend.domain.WeatherView.Now;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Forecast, PV estimate and warnings, each kept for a while so the phones do not load the sources. */
|
||||
public class WeatherService {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||
private static final int CALIBRATION_DAYS = 14;
|
||||
|
||||
private record Calibrated(Forecast forecast, Double factor) {
|
||||
}
|
||||
|
||||
private final Clock clock;
|
||||
private final PvEnergyRepository pv;
|
||||
private final Cached<Calibrated> forecast;
|
||||
private final Cached<List<Warning>> warnings;
|
||||
|
||||
public WeatherService(ForecastSource forecastSource, WarningSource warningSource, PvEnergyRepository pv,
|
||||
Clock clock) {
|
||||
this.clock = clock;
|
||||
this.pv = pv;
|
||||
this.forecast = new Cached<>(Duration.ofMinutes(30), clock, () -> calibrated(forecastSource.forecast()));
|
||||
this.warnings = new Cached<>(Duration.ofMinutes(10), clock, warningSource::warnings);
|
||||
}
|
||||
|
||||
/** @throws IOException when there is no forecast at all; warnings alone may be missing. */
|
||||
public WeatherView current() throws IOException {
|
||||
Entry<Calibrated> weather = forecast.get();
|
||||
Entry<List<Warning>> warned = warnings();
|
||||
LocalDate today = LocalDate.now(clock.withZone(ZONE));
|
||||
Calibrated w = weather.value();
|
||||
return new WeatherView(clock.instant(),
|
||||
day(w, today), day(w, today.plusDays(1)), day(w, today.plusDays(2)),
|
||||
new Now(w.forecast().temperature()), inForce(warned.value()), w.factor(),
|
||||
weather.stale() || warned.stale());
|
||||
}
|
||||
|
||||
private Entry<List<Warning>> warnings() {
|
||||
try {
|
||||
return warnings.get();
|
||||
} catch (IOException e) {
|
||||
return new Entry<>(List.of(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Warning> inForce(List<Warning> all) {
|
||||
return all.stream().filter(w -> w.to().toInstant().isAfter(clock.instant())).toList();
|
||||
}
|
||||
|
||||
private Calibrated calibrated(Forecast fetched) {
|
||||
LocalDate today = LocalDate.now(clock.withZone(ZONE));
|
||||
Map<LocalDate, Double> radiation = new HashMap<>();
|
||||
fetched.days().forEach((date, day) -> {
|
||||
if (day.radiationMj() != null) {
|
||||
radiation.put(date, day.radiationMj());
|
||||
}
|
||||
});
|
||||
try {
|
||||
var measured = pv.dailyKwh(today.minusDays(CALIBRATION_DAYS), today);
|
||||
return new Calibrated(fetched, PvCalibration.factor(measured, radiation));
|
||||
} catch (IOException e) {
|
||||
return new Calibrated(fetched, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static Day day(Calibrated w, LocalDate date) throws IOException {
|
||||
DailyForecast d = w.forecast().days().get(date);
|
||||
if (d == null) {
|
||||
throw new IOException("forecast has no " + date);
|
||||
}
|
||||
Double kwh = w.factor() == null || d.radiationMj() == null ? null : round(w.factor() * d.radiationMj());
|
||||
Double sunshine = d.sunshineSeconds() == null ? null : round(d.sunshineSeconds() / 3600);
|
||||
return new Day(date, d.weatherCode(), WeatherCodes.describe(d.weatherCode()), d.tMax(), d.tMin(),
|
||||
d.precipitationMm(), sunshine, kwh);
|
||||
}
|
||||
|
||||
private static double round(double value) {
|
||||
return Math.round(value * 10) / 10.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* One audited write. {@code unit} is "°C", "%", "HHMM" (a clock time written as 1830) or "0/1" (off/on);
|
||||
* the values are null when the gateway did not record them.
|
||||
*/
|
||||
public record AuditEntry(Instant time, String source, String what, Integer oldValue, Integer newValue,
|
||||
String unit, String who, String status) {
|
||||
|
||||
public static final String HEAT_PUMP = "heat_pump";
|
||||
public static final String BATTERY = "battery";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The audited writes of the heat pump and the battery, newest first. */
|
||||
public record AuditView(List<AuditEntry> entries) {
|
||||
}
|
||||
@@ -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,13 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/** What the forecast service says: the temperature now and a record per calendar day, past days included. */
|
||||
public record Forecast(Double temperature, Map<LocalDate, DailyForecast> days) {
|
||||
|
||||
/** Radiation in MJ/m² per day, sunshine in seconds; any value may be missing. */
|
||||
public record DailyForecast(Integer weatherCode, Double tMax, Double tMin, Double precipitationMm,
|
||||
Double radiationMj, Double sunshineSeconds) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
|
||||
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co, Double heater,
|
||||
Double outdoorTemp, Double cwuTemp) {
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The PV output per unit of radiation, found from past days instead of the panels' tilt and azimuth:
|
||||
* measured kWh over forecast MJ/m², summed over the days that have both.
|
||||
*/
|
||||
public final class PvCalibration {
|
||||
|
||||
static final int MIN_DAYS = 5;
|
||||
|
||||
private PvCalibration() {
|
||||
}
|
||||
|
||||
/** Null with fewer than {@value MIN_DAYS} days having both values or no radiation at all. */
|
||||
public static Double factor(Map<LocalDate, Double> measuredKwh, Map<LocalDate, Double> radiationMj) {
|
||||
double kwh = 0;
|
||||
double mj = 0;
|
||||
int days = 0;
|
||||
for (var day : measuredKwh.entrySet()) {
|
||||
Double radiation = radiationMj.get(day.getKey());
|
||||
if (radiation != null) {
|
||||
kwh += day.getValue();
|
||||
mj += radiation;
|
||||
days++;
|
||||
}
|
||||
}
|
||||
return days < MIN_DAYS || mj <= 0 ? null : kwh / mj;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Heat pump work per local day (F2). Hours are approximated from the polls, starts are counted
|
||||
* between polls, so short cycles are missed. A day without polls has null values.
|
||||
*/
|
||||
public record StatsView(String range, List<Day> days, Totals totals) {
|
||||
|
||||
public record Day(LocalDate date, Double compressorHours, Integer compressorStarts, Double cwuHours,
|
||||
Double coHours, Double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
|
||||
}
|
||||
|
||||
public record Totals(double compressorHours, int compressorStarts, double cwuHours, double coHours,
|
||||
double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package home.backend.domain;
|
||||
|
||||
/** WMO weather interpretation codes as Polish text. */
|
||||
public final class WeatherCodes {
|
||||
|
||||
private WeatherCodes() {
|
||||
}
|
||||
|
||||
public static String describe(Integer code) {
|
||||
if (code == null) {
|
||||
return "Brak danych";
|
||||
}
|
||||
return switch (code) {
|
||||
case 0 -> "Bezchmurnie";
|
||||
case 1 -> "Przeważnie słonecznie";
|
||||
case 2 -> "Częściowe zachmurzenie";
|
||||
case 3 -> "Pochmurno";
|
||||
case 45, 48 -> "Mgła";
|
||||
case 51, 53, 55 -> "Mżawka";
|
||||
case 56, 57 -> "Marznąca mżawka";
|
||||
case 61 -> "Słaby deszcz";
|
||||
case 63 -> "Deszcz";
|
||||
case 65 -> "Silny deszcz";
|
||||
case 66, 67 -> "Marznący deszcz";
|
||||
case 71 -> "Słaby śnieg";
|
||||
case 73 -> "Śnieg";
|
||||
case 75 -> "Intensywny śnieg";
|
||||
case 77 -> "Ziarna śnieżne";
|
||||
case 80, 81 -> "Przelotny deszcz";
|
||||
case 82 -> "Ulewa";
|
||||
case 85, 86 -> "Przelotny śnieg";
|
||||
case 95 -> "Burza";
|
||||
case 96, 99 -> "Burza z gradem";
|
||||
default -> "Brak danych";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Weather at the house: three days, the temperature now and the IMGW warnings in force or announced.
|
||||
* {@code pvFactor} (kWh per MJ/m² of forecast radiation) and every {@code pvForecastKwh} are null while
|
||||
* there are too few measured days to calibrate. {@code stale} means part of it is an older answer
|
||||
* because a source could not be reached.
|
||||
*/
|
||||
public record WeatherView(Instant time, Day today, Day tomorrow, Day dayAfter, Now now,
|
||||
List<Warning> warnings, Double pvFactor, boolean stale) {
|
||||
|
||||
public record Day(LocalDate date, Integer weatherCode, String description, Double tMax, Double tMin,
|
||||
Double precipitationMm, Double sunshineHours, Double pvForecastKwh) {
|
||||
}
|
||||
|
||||
public record Now(Double temperature) {
|
||||
}
|
||||
|
||||
/** {@code level} is IMGW's degree 1 to 3. */
|
||||
public record Warning(String event, int level, OffsetDateTime from, OffsetDateTime to, String text) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -59,16 +65,20 @@ class GatewayHttpCommandsTest {
|
||||
}
|
||||
|
||||
private void answerCommands() {
|
||||
server.createContext("/commands", exchange -> {
|
||||
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
|
||||
exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
for (String path : List.of("/commands", "/battery")) {
|
||||
server.createContext(path, this::answer);
|
||||
}
|
||||
}
|
||||
|
||||
private void answer(HttpExchange exchange) throws IOException {
|
||||
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
|
||||
exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
private GatewayHttpCommands commands(String token) {
|
||||
@@ -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"));
|
||||
@@ -144,6 +163,47 @@ class GatewayHttpCommandsTest {
|
||||
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}");
|
||||
@@ -165,6 +225,79 @@ class GatewayHttpCommandsTest {
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheBatteryReserveWithTheToken() throws Exception {
|
||||
serve(200, contract("battery-reserve.json"));
|
||||
|
||||
var reserve = commands("s3cret-token").reserve();
|
||||
|
||||
assertEquals(new BatteryView.Reserve(50, 30), reserve);
|
||||
assertEquals("GET", seen.getFirst().method());
|
||||
assertEquals("/battery/reserve", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsAScheduleChangeAsTheGatewayExpectsIt() throws Exception {
|
||||
serve(200, contract("response-battery-applied.json"));
|
||||
var change = new BatteryChange(true, List.of(new BatteryChange.Slot(2, LocalTime.of(9, 30), 90, true),
|
||||
new BatteryChange.Slot(3, null, 85, null)));
|
||||
|
||||
var result = commands("s3cret-token").changeSchedule(change, "anna");
|
||||
|
||||
assertEquals(BatteryResult.Status.APPLIED, result.status());
|
||||
assertEquals(java.util.Map.of(167, 90, 172, 1), result.read());
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/battery/schedule", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals("anna", seen.getFirst().requestedBy());
|
||||
assertEquals(contractJson("request-battery-schedule.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsAReserveChange() throws Exception {
|
||||
serve(200, contract("response-battery-applied.json"));
|
||||
|
||||
commands("s3cret-token").changeReserve(60, "anna");
|
||||
|
||||
assertEquals("/battery/reserve", seen.getFirst().path());
|
||||
assertEquals(contractJson("{\"pct\":60}"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyBatteryStatusOfTheGatewayIsAVerdict() throws Exception {
|
||||
var expected = java.util.Map.of(422, BatteryResult.Status.REJECTED, 409, BatteryResult.Status.STALE,
|
||||
429, BatteryResult.Status.TOO_SOON, 502, BatteryResult.Status.FAILED);
|
||||
for (var entry : expected.entrySet()) {
|
||||
serve(entry.getKey(), "{\"status\":\"" + entry.getValue() + "\",\"message\":\"x\",\"read\":{}}");
|
||||
|
||||
assertEquals(entry.getValue(), commands("s3cret-token").changeReserve(60, "anna").status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedBatteryWriteCarriesWhatWasRead() throws Exception {
|
||||
serve(502, contract("response-battery-failed.json"));
|
||||
|
||||
var result = commands("s3cret-token").changeSchedule(new BatteryChange(null, null), "anna");
|
||||
|
||||
assertEquals(BatteryResult.Status.FAILED, result.status());
|
||||
assertEquals(java.util.Map.of(167, 80), result.read());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryCallsFailWithoutAVerdictOrToken() {
|
||||
for (int status : List.of(400, 401, 404, 503)) {
|
||||
serve(status, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").reserve(), "GET " + status);
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").changeReserve(60, "anna"), "POST " + status);
|
||||
}
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).reserve());
|
||||
assertThrows(IOException.class, () -> commands(null).changeReserve(60, "anna"));
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
|
||||
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
|
||||
|
||||
@@ -2,25 +2,41 @@ package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.AuditRepository;
|
||||
import home.backend.application.AuditService;
|
||||
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;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.application.HeatPumpSamples;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.WeatherService;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -36,11 +52,14 @@ import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -55,10 +74,17 @@ class ApiServerTest {
|
||||
private GatewaySnapshot snapshot;
|
||||
private boolean gatewayDown;
|
||||
private boolean historyDown;
|
||||
private boolean auditDown;
|
||||
private HeatPumpSamples samples;
|
||||
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,7 +108,18 @@ class ApiServerTest {
|
||||
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
|
||||
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
|
||||
};
|
||||
samples = (from, to) -> {
|
||||
if (historyDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return List.of(
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 0.0, 8.0, 44.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 0.0, 8.5, 43.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 100.0, 9.0, 45.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 0.0, 9.0, 46.0));
|
||||
};
|
||||
verdict = Status.APPLIED;
|
||||
restoreOutcome = Outcome.RESTORED;
|
||||
answersBeforeDrop = Integer.MAX_VALUE;
|
||||
GatewayCommands commands = new GatewayCommands() {
|
||||
@Override
|
||||
@@ -99,6 +136,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) {
|
||||
@@ -116,11 +162,49 @@ class ApiServerTest {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
batteryDown = false;
|
||||
batteryVerdict = BatteryResult.Status.APPLIED;
|
||||
GatewayBattery batteryCommands = new GatewayBattery() {
|
||||
@Override
|
||||
public Reserve reserve() throws IOException {
|
||||
if (batteryDown) {
|
||||
throw new IOException("gateway /battery/reserve answered 404");
|
||||
}
|
||||
return new Reserve(50, 30);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
|
||||
return answer("schedule " + change + " by " + requestedBy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
|
||||
return answer("reserve " + pct + " by " + requestedBy);
|
||||
}
|
||||
|
||||
private BatteryResult answer(String what) throws IOException {
|
||||
if (batteryDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
batterySent.add(what);
|
||||
return switch (batteryVerdict) {
|
||||
case APPLIED -> new BatteryResult(batteryVerdict, "applied", Map.of(167, 90));
|
||||
case FAILED -> new BatteryResult(batteryVerdict,
|
||||
"registers not as written: [167]; not retried", Map.of(167, 80));
|
||||
default -> new BatteryResult(batteryVerdict, "nope", Map.of());
|
||||
};
|
||||
}
|
||||
};
|
||||
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
|
||||
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
|
||||
new SetpointService(commands, PresetFile.load(null)));
|
||||
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, batteryCommands, clock),
|
||||
new AuditService(auditRows(), clock), new StatsService(samples, clock),
|
||||
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
|
||||
(from, to) -> exampleMeasuredPv(), clock));
|
||||
server.start();
|
||||
}
|
||||
|
||||
@@ -129,6 +213,27 @@ class ApiServerTest {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
private AuditRepository auditRows() {
|
||||
return new AuditRepository() {
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) throws IOException {
|
||||
if (auditDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return List.of(
|
||||
new Row(Instant.parse("2026-10-24T08:00:00Z"), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
|
||||
new Row(Instant.parse("2026-10-23T07:00:00Z"), "co_comfort", "mariusz", "REJECTED", null, 22.0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) {
|
||||
return List.of(
|
||||
new Row(Instant.parse("2026-10-24T09:30:00Z"), "reserve", "mariusz", "APPLIED", 50.0, 60.0),
|
||||
new Row(Instant.parse("2026-10-23T18:00:00Z"), "174", "mariusz", "FAILED", 0.0, 1.0));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static GatewaySnapshot exampleSnapshot() {
|
||||
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
|
||||
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
|
||||
@@ -140,10 +245,13 @@ class ApiServerTest {
|
||||
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
|
||||
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
|
||||
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
|
||||
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||
Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
|
||||
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
|
||||
"load_today_kwh", 24.2);
|
||||
"load_today_kwh", 24.2));
|
||||
deye.putAll(Map.of("battery_voltage_v", 56.49, "battery_current_a", -0.05, "battery_temperature_c", 19.2,
|
||||
"battery_charge_today_kwh", 0.5, "battery_discharge_today_kwh", 0.1,
|
||||
"battery_charge_total_kwh", 453.7, "battery_discharge_total_kwh", 162.1));
|
||||
return new GatewaySnapshot("v1.2.1", List.of(
|
||||
new DeviceSnapshot("heat-pump", true, pumpTime, null,
|
||||
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
|
||||
@@ -155,7 +263,28 @@ class ApiServerTest {
|
||||
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)))));
|
||||
"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 {
|
||||
@@ -164,6 +293,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);
|
||||
@@ -218,16 +351,196 @@ class ApiServerTest {
|
||||
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());
|
||||
}
|
||||
|
||||
private static Forecast exampleForecast() {
|
||||
Map<LocalDate, Forecast.DailyForecast> days = new TreeMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
days.put(LocalDate.parse("2026-10-19").plusDays(i), new Forecast.DailyForecast(3, 12.0, 4.0, 0.0, 10.0, 7200.0));
|
||||
}
|
||||
days.put(LocalDate.parse("2026-10-24"), new Forecast.DailyForecast(3, 10.9, 3.8, 0.0, 9.0, 7151.0));
|
||||
days.put(LocalDate.parse("2026-10-25"), new Forecast.DailyForecast(51, 13.2, 4.0, 0.3, 4.5, 28314.0));
|
||||
days.put(LocalDate.parse("2026-10-26"), new Forecast.DailyForecast(61, 9.6, 4.1, 8.4, 2.1, 0.0));
|
||||
return new Forecast(10.6, days);
|
||||
}
|
||||
|
||||
/** Twice the forecast radiation of the same days, so the factor is 2 kWh per MJ/m². */
|
||||
private static Map<LocalDate, Double> exampleMeasuredPv() {
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
measured.put(LocalDate.parse("2026-10-19").plusDays(i), 20.0);
|
||||
}
|
||||
return measured;
|
||||
}
|
||||
|
||||
private static Warning exampleWarning() {
|
||||
return new Warning("Silny wiatr", 2, OffsetDateTime.parse("2026-10-25T08:00:00+01:00"),
|
||||
OffsetDateTime.parse("2026-10-25T18:00:00+01:00"),
|
||||
"Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void weatherMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/weather", "weather");
|
||||
}
|
||||
|
||||
@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");
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/stats?range=7d", "stats");
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsNeedAKnownRange() throws Exception {
|
||||
assertEquals(400, get("/api/stats").statusCode());
|
||||
assertEquals(400, get("/api/stats?range=24h").statusCode());
|
||||
assertEquals(200, get("/api/stats?range=30d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsAre502WhenInfluxIsDown() throws Exception {
|
||||
historyDown = true;
|
||||
|
||||
assertEquals(502, get("/api/stats?range=7d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyRangeDefaultsTo24Hours() throws Exception {
|
||||
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/audit?range=7d", "audit");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditRangeDefaultsAndIsChecked() throws Exception {
|
||||
assertEquals(200, get("/api/audit").statusCode());
|
||||
assertEquals(400, get("/api/audit?range=90d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditInfluxFailureIs502() throws Exception {
|
||||
auditDown = true;
|
||||
|
||||
assertEquals(502, get("/api/audit?range=7d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayIs503WithJsonError() throws Exception {
|
||||
gatewayDown = true;
|
||||
@@ -317,6 +630,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;
|
||||
@@ -359,7 +718,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());
|
||||
@@ -372,12 +731,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());
|
||||
@@ -388,7 +801,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
|
||||
@@ -397,7 +810,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,105 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.application.AuditRepository.Row;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class InfluxAuditRepositoryTest {
|
||||
|
||||
private static final Instant FROM = Instant.parse("2026-10-17T12:00:00Z");
|
||||
private static final Instant TO = Instant.parse("2026-10-24T12:00:00Z");
|
||||
|
||||
private HttpServer server;
|
||||
private final List<String> queries = Collections.synchronizedList(new ArrayList<>());
|
||||
private String body;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/query", exchange -> {
|
||||
queries.add(URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsHeatPumpRowsFromTheLegacyDatabase() throws IOException {
|
||||
body = fixture("influx-audit-setpoints.json");
|
||||
|
||||
List<Row> rows = repository().heatPump(FROM, TO);
|
||||
|
||||
assertEquals(2, rows.size());
|
||||
assertEquals(new Row(Instant.ofEpochMilli(1761292800000L), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
|
||||
rows.get(0));
|
||||
assertNull(rows.get(1).oldValue());
|
||||
assertEquals("co_comfort", rows.get(1).target());
|
||||
assertTrue(queries.getFirst().startsWith("db=Dom_Pompa&epoch=ms&q=SELECT * FROM \"setpoint_audit\" "
|
||||
+ "WHERE time >= " + FROM.toEpochMilli() + "ms AND time < " + TO.toEpochMilli() + "ms"),
|
||||
queries.getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsBatteryRowsByRegisterTag() throws IOException {
|
||||
body = fixture("influx-audit-battery.json");
|
||||
|
||||
List<Row> rows = repository().battery(FROM, TO);
|
||||
|
||||
assertEquals(List.of("reserve", "174"), rows.stream().map(Row::target).toList());
|
||||
assertEquals(60.0, rows.getFirst().newValue());
|
||||
assertTrue(queries.getFirst().startsWith("db=electricity&"), queries.getFirst());
|
||||
assertTrue(queries.getFirst().contains("\"deye_settings_audit\""), queries.getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noRowsIsAnEmptyList() throws IOException {
|
||||
body = fixture("influx-query-empty.json");
|
||||
|
||||
assertTrue(repository().battery(FROM, TO).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anInfluxErrorIsAnIOException() throws IOException {
|
||||
body = fixture("influx-query-error.json");
|
||||
|
||||
assertThrows(IOException.class, () -> repository().heatPump(FROM, TO));
|
||||
}
|
||||
|
||||
private InfluxAuditRepository repository() {
|
||||
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
return new InfluxAuditRepository(new InfluxSettings(base, Duration.ofSeconds(5)));
|
||||
}
|
||||
|
||||
private static String fixture(String name) throws IOException {
|
||||
return Files.readString(Path.of("src/test/resources/influx", name));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -208,6 +209,35 @@ class InfluxHistoryRepositoryTest {
|
||||
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsRawHeatPumpPollsFromTheLegacyDatabase() throws IOException {
|
||||
defaultBody = fixture("influx-query-heat-pump.json");
|
||||
|
||||
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
|
||||
|
||||
assertEquals("Dom_Pompa", requests.get(0).get("db"));
|
||||
assertEquals("SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
|
||||
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
|
||||
requests.get(0).get("q"));
|
||||
assertEquals(3, samples.size());
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 0.0, 8.5, 43.9),
|
||||
samples.get(1));
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null),
|
||||
samples.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noHeatPumpPollsIsAnEmptyList() throws IOException {
|
||||
assertTrue(repository(TIMEOUT).samples(FROM, TO).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpQueryErrorIsAnIoException() throws IOException {
|
||||
defaultBody = fixture("influx-query-error.json");
|
||||
|
||||
assertThrows(IOException.class, () -> repository(TIMEOUT).samples(FROM, TO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnEmptyRange() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
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;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class InfluxPvEnergyTest {
|
||||
|
||||
private static final LocalDate FROM = LocalDate.parse("2026-10-19");
|
||||
private static final LocalDate TO = LocalDate.parse("2026-10-24");
|
||||
|
||||
private HttpServer server;
|
||||
private InfluxPvEnergy pv;
|
||||
private final List<String> queries = new ArrayList<>();
|
||||
private int status = 200;
|
||||
/** Day buckets start at Warsaw midnight: 2026-10-19 is 2026-10-18T22:00Z. */
|
||||
private String deye = rows("1792360800000", 22.5, "1792447200000", 8.0);
|
||||
private String fronius = rows("1792360800000", 4400.0, "1792533600000", 100.0);
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/query", exchange -> {
|
||||
String query = URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8);
|
||||
queries.add(query);
|
||||
byte[] bytes = (query.contains("\"deye\"") ? deye : fronius).getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
var settings = new InfluxSettings(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
|
||||
Duration.ofSeconds(2));
|
||||
pv = new InfluxPvEnergy(settings);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private static String rows(String t1, double v1, String t2, double v2) {
|
||||
return "{\"results\":[{\"statement_id\":0,\"series\":[{\"name\":\"m\",\"columns\":[\"time\",\"max\"],"
|
||||
+ "\"values\":[[" + t1 + "," + v1 + "],[" + t2 + "," + v2 + "]]}]}]}";
|
||||
}
|
||||
|
||||
@Test
|
||||
void addsTheDeyeAndFroniusDayCountersPerLocalDay() throws IOException {
|
||||
Map<LocalDate, Double> days = pv.dailyKwh(FROM, TO);
|
||||
|
||||
assertEquals(Map.of(FROM, 22.5 + 4.4), days);
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksForTheDailyMaximumOfEachCounterInWarsawDays() throws IOException {
|
||||
pv.dailyKwh(FROM, TO);
|
||||
|
||||
assertEquals(2, queries.size());
|
||||
String deyeQuery = queries.stream().filter(q -> q.contains("\"deye\"")).findFirst().orElseThrow();
|
||||
assertTrue(deyeQuery.contains("db=electricity"), deyeQuery);
|
||||
assertTrue(deyeQuery.contains("SELECT max(\"pv_today_kwh\") FROM \"deye\" WHERE time >= 1792360800000ms"
|
||||
+ " AND time < 1792792800000ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')"), deyeQuery);
|
||||
assertTrue(queries.stream().anyMatch(q -> q.contains("max(\"energy_day_wh\") FROM \"fronius\"")), queries.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failureIsAnIoError() {
|
||||
status = 500;
|
||||
|
||||
assertThrows(IOException.class, () -> pv.dailyKwh(FROM, TO));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
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.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** The two clients against a local server that answers with responses recorded from the real services. */
|
||||
class WeatherClientsTest {
|
||||
|
||||
private static final WeatherSettings PLACE = WeatherSettings.fromEnvironment(Map.of());
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(2);
|
||||
|
||||
private HttpServer server;
|
||||
private URI base;
|
||||
private final List<String> requests = new ArrayList<>();
|
||||
private int status = 200;
|
||||
private String body = "";
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
requests.add(exchange.getRequestURI().toString());
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private static String fixture(String name) throws IOException {
|
||||
return Files.readString(Path.of("src/test/resources/weather", name));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheOpenMeteoForecastForTheHouse() throws IOException {
|
||||
body = fixture("open-meteo.json");
|
||||
|
||||
Forecast forecast = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast();
|
||||
|
||||
assertEquals(10.6, forecast.temperature());
|
||||
assertEquals(17, forecast.days().size());
|
||||
var today = forecast.days().get(LocalDate.parse("2026-10-24"));
|
||||
assertEquals(3, today.weatherCode());
|
||||
assertEquals(10.9, today.tMax());
|
||||
assertEquals(3.8, today.tMin());
|
||||
assertEquals(6.88, today.radiationMj());
|
||||
assertEquals(7151.32, today.sunshineSeconds());
|
||||
assertEquals(8.4, forecast.days().get(LocalDate.parse("2026-10-26")).precipitationMm());
|
||||
String request = requests.getFirst();
|
||||
for (String part : List.of("latitude=49.684", "longitude=21.77", "timezone=Europe%2FWarsaw",
|
||||
"past_days=14", "shortwave_radiation_sum", "sunshine_duration")) {
|
||||
assertEquals(true, request.contains(part), part + " in " + request);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingValueInTheForecastIsNull() throws IOException {
|
||||
body = """
|
||||
{"daily":{"time":["2026-10-24"],"weather_code":[null],"temperature_2m_max":[1.5]}}""";
|
||||
|
||||
var day = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast().days().get(LocalDate.parse("2026-10-24"));
|
||||
|
||||
assertNull(day.weatherCode());
|
||||
assertEquals(1.5, day.tMax());
|
||||
assertNull(day.tMin());
|
||||
assertNull(day.radiationMj());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOpenMeteoFailureIsAnIoError() {
|
||||
body = "{\"error\":true}";
|
||||
status = 500;
|
||||
var client = new OpenMeteoClient(base, PLACE, TIMEOUT);
|
||||
assertThrows(IOException.class, client::forecast);
|
||||
|
||||
status = 200;
|
||||
body = "not json";
|
||||
assertThrows(IOException.class, client::forecast);
|
||||
|
||||
body = "{\"daily\":{}}";
|
||||
assertThrows(IOException.class, client::forecast);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsWarningsThatCoverTheCityOrThePowiatAndMapsThem() throws IOException {
|
||||
body = fixture("imgw-warnings.json");
|
||||
|
||||
List<Warning> warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings();
|
||||
|
||||
assertEquals("/api/data/warningsmeteo", requests.getFirst());
|
||||
assertEquals(2, warnings.size());
|
||||
Warning city = warnings.get(0);
|
||||
assertEquals("Silny wiatr", city.event());
|
||||
assertEquals(2, city.level());
|
||||
assertEquals(OffsetDateTime.parse("2026-10-25T08:00:00+01:00"), city.from());
|
||||
assertEquals(OffsetDateTime.parse("2026-10-25T18:00:00+01:00"), city.to());
|
||||
assertEquals("Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h, "
|
||||
+ "w porywach do 90 km/h, z południa.", city.text());
|
||||
assertEquals(1, warnings.get(1).level());
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherAreasAreIgnoredAndTerytIsConfigurable() throws IOException {
|
||||
body = fixture("imgw-warnings.json");
|
||||
var cityOnly = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("1862"));
|
||||
var elsewhere = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("2804"));
|
||||
|
||||
assertEquals(1, new ImgwClient(base, cityOnly, TIMEOUT).warnings().size());
|
||||
assertEquals(1, new ImgwClient(base, elsewhere, TIMEOUT).warnings().size());
|
||||
assertEquals(0, new ImgwClient(base, new WeatherSettings(0, 0, List.of("0000")), TIMEOUT).warnings().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMalformedWarningIsSkippedNotFatal() throws IOException {
|
||||
body = """
|
||||
[{"nazwa_zdarzenia":"Burze","stopien":"x","obowiazuje_od":"2026-10-25 08:00:00",
|
||||
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"a","teryt":["1862"]},
|
||||
{"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"soon",
|
||||
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"b","teryt":["1862"]},
|
||||
{"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"2026-10-25 08:00:00",
|
||||
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"c","teryt":["1862"]}]""";
|
||||
|
||||
List<Warning> warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings();
|
||||
|
||||
assertEquals(1, warnings.size());
|
||||
assertEquals("c", warnings.getFirst().text());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anImgwFailureIsAnIoError() {
|
||||
body = "{}";
|
||||
var client = new ImgwClient(base, PLACE, TIMEOUT);
|
||||
assertThrows(IOException.class, client::warnings);
|
||||
|
||||
status = 503;
|
||||
assertThrows(IOException.class, client::warnings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsDefaultToKrosnoAndRejectNonsense() {
|
||||
assertEquals(49.684, PLACE.latitude());
|
||||
assertEquals(21.770, PLACE.longitude());
|
||||
assertEquals(List.of("1862", "1807"), PLACE.teryt());
|
||||
|
||||
var custom = WeatherSettings.fromEnvironment(
|
||||
Map.of("WEATHER_LAT", "50.1", "WEATHER_LON", "-20", "WEATHER_TERYT", "1465, 1401"));
|
||||
assertEquals(50.1, custom.latitude());
|
||||
assertEquals(-20.0, custom.longitude());
|
||||
assertEquals(List.of("1465", "1401"), custom.teryt());
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LAT", "north")));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LON", "200")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.AuditRepository.Row;
|
||||
import home.backend.domain.AuditEntry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class AuditServiceTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
|
||||
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
|
||||
private List<Row> heatPump = List.of();
|
||||
private List<Row> battery = List.of();
|
||||
private final List<Instant> asked = new ArrayList<>();
|
||||
|
||||
private final AuditRepository repository = new AuditRepository() {
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) {
|
||||
asked.add(from);
|
||||
return heatPump;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) throws IOException {
|
||||
if (battery == null) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return battery;
|
||||
}
|
||||
};
|
||||
private final AuditService service = new AuditService(repository, CLOCK);
|
||||
|
||||
private static Row row(String time, String target, String by, Double old, Double value) {
|
||||
return new Row(Instant.parse(time), target, by, "APPLIED", old, value);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergesBothSourcesNewestFirst() throws IOException {
|
||||
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "a", 8.0, 45.0),
|
||||
row("2026-10-22T08:00:00Z", "co_comfort", "a", 19.0, 20.0));
|
||||
battery = List.of(row("2026-10-23T08:00:00Z", "reserve", "b", 50.0, 60.0));
|
||||
|
||||
var entries = service.audit(null).entries();
|
||||
|
||||
assertEquals(List.of("CWU eko", "Rezerwa baterii", "CO komfort"),
|
||||
entries.stream().map(AuditEntry::what).toList());
|
||||
assertEquals(List.of("heat_pump", "battery", "heat_pump"),
|
||||
entries.stream().map(AuditEntry::source).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namesTheDeyeRegisters() throws IOException {
|
||||
battery = List.of("146", "148", "153", "166", "171", "172", "177", "reserve", "999").stream()
|
||||
.map(register -> row("2026-10-24T08:00:00Z", register, "a", 0.0, 1.0)).toList();
|
||||
|
||||
var entries = service.audit("24h").entries();
|
||||
|
||||
assertEquals(List.of("Harmonogram baterii/0/1", "Przedział 1: start/HHMM", "Przedział 6: start/HHMM",
|
||||
"Przedział 1: poziom/%", "Przedział 6: poziom/%", "Przedział 1: ładowanie z sieci/0/1",
|
||||
"Przedział 6: ładowanie z sieci/0/1", "Rezerwa baterii/%", "Rejestr 999/"),
|
||||
entries.stream().map(e -> e.what() + "/" + e.unit()).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsARestoreAsAutomaticAndKeepsMissingValuesNull() throws IOException {
|
||||
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "restore", null, 8.4));
|
||||
|
||||
var entry = service.audit("7d").entries().getFirst();
|
||||
|
||||
assertEquals("automatycznie (przywrócenie)", entry.who());
|
||||
assertNull(entry.oldValue());
|
||||
assertEquals(8, entry.newValue());
|
||||
assertEquals("°C", entry.unit());
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsToSevenDaysAndAllowsUpToThirty() throws IOException {
|
||||
service.audit(null);
|
||||
service.audit("30d");
|
||||
|
||||
assertEquals(List.of(NOW.minus(Duration.ofDays(7)), NOW.minus(Duration.ofDays(30))), asked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnknownRange() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.audit("90d"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.audit(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenOneSourceFails() {
|
||||
battery = null;
|
||||
|
||||
assertThrows(IOException.class, () -> service.audit("7d"));
|
||||
}
|
||||
}
|
||||
@@ -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 org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -26,6 +27,11 @@ class LeaseRenewerTest {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() {
|
||||
throw new UnsupportedOperationException();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView;
|
||||
import home.backend.domain.EnergyDetails;
|
||||
import home.backend.domain.EnergyDetails.Device;
|
||||
import home.backend.domain.EnergyDetails.Group;
|
||||
@@ -13,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;
|
||||
@@ -22,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;
|
||||
|
||||
@@ -170,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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -64,6 +79,20 @@ class SetpointServiceTest {
|
||||
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();
|
||||
@@ -98,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));
|
||||
@@ -134,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());
|
||||
@@ -150,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));
|
||||
@@ -161,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));
|
||||
@@ -171,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));
|
||||
@@ -181,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));
|
||||
@@ -191,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));
|
||||
@@ -203,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,214 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.StatsView;
|
||||
import home.backend.domain.StatsView.Day;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class StatsServiceTest {
|
||||
|
||||
/** Monday 2026-10-26 11:00 in Warsaw; the week before it holds the change to winter time. */
|
||||
private static final Instant NOW = Instant.parse("2026-10-26T10:00:00Z");
|
||||
|
||||
private Instant askedFrom;
|
||||
private Instant askedTo;
|
||||
|
||||
private StatsView stats(String range, Instant now, HeatPumpSample... samples) throws IOException {
|
||||
HeatPumpSamples repository = (from, to) -> {
|
||||
askedFrom = from;
|
||||
askedTo = to;
|
||||
return List.of(samples);
|
||||
};
|
||||
return new StatsService(repository, Clock.fixed(now, ZoneOffset.UTC)).stats(range);
|
||||
}
|
||||
|
||||
private static HeatPumpSample compressor(String time, double stage) {
|
||||
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, 0.0, null, null);
|
||||
}
|
||||
|
||||
private static Day day(StatsView view, String date) {
|
||||
return view.days().stream().filter(d -> d.date().equals(LocalDate.parse(date))).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksForWholeLocalDaysUpToNow() throws IOException {
|
||||
stats("7d", NOW);
|
||||
|
||||
assertEquals(Instant.parse("2026-10-19T22:00:00Z"), askedFrom);
|
||||
assertEquals(NOW, askedTo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsOneDayPerLocalDateOldestFirst() throws IOException {
|
||||
StatsView view = stats("30d", NOW);
|
||||
|
||||
assertEquals(30, view.days().size());
|
||||
assertEquals(LocalDate.parse("2026-09-27"), view.days().getFirst().date());
|
||||
assertEquals(LocalDate.parse("2026-10-26"), view.days().getLast().date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDataGivesEmptyDaysAndZeroTotals() throws IOException {
|
||||
StatsView view = stats("7d", NOW);
|
||||
|
||||
assertEquals(7, view.days().size());
|
||||
for (Day d : view.days()) {
|
||||
assertNull(d.compressorHours());
|
||||
assertNull(d.compressorStarts());
|
||||
assertNull(d.avgOutdoorTemp());
|
||||
}
|
||||
assertEquals(0.0, view.totals().compressorHours());
|
||||
assertEquals(0, view.totals().compressorStarts());
|
||||
assertNull(view.totals().avgOutdoorTemp());
|
||||
assertNull(view.totals().minCwuTemp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGapIsCappedAtTenMinutesPerPoll() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-23T10:00:00Z", 100),
|
||||
compressor("2026-10-23T10:05:00Z", 100),
|
||||
compressor("2026-10-23T11:00:00Z", 0));
|
||||
|
||||
assertEquals(0.25, day(view, "2026-10-23").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theLastPollHoldsUntilNowAtMostTenMinutes() throws IOException {
|
||||
StatsView recent = stats("7d", NOW, compressor("2026-10-26T09:58:00Z", 50));
|
||||
StatsView old = stats("7d", NOW, compressor("2026-10-26T08:00:00Z", 50));
|
||||
|
||||
assertEquals(0.03, day(recent, "2026-10-26").compressorHours());
|
||||
assertEquals(0.17, day(old, "2026-10-26").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsOnlyTransitionsFromOffToOn() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-23T10:00:00Z", 100),
|
||||
compressor("2026-10-23T10:05:00Z", 100),
|
||||
compressor("2026-10-23T10:10:00Z", 0),
|
||||
compressor("2026-10-23T10:15:00Z", 0),
|
||||
compressor("2026-10-23T10:20:00Z", 50),
|
||||
compressor("2026-10-23T10:25:00Z", 100),
|
||||
compressor("2026-10-23T10:30:00Z", 0),
|
||||
compressor("2026-10-23T10:35:00Z", 100));
|
||||
|
||||
assertEquals(2, day(view, "2026-10-23").compressorStarts());
|
||||
assertEquals(2, view.totals().compressorStarts());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aStartAtMidnightBelongsToTheNewDay() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-24T21:55:00Z", 0),
|
||||
compressor("2026-10-24T22:00:00Z", 100));
|
||||
|
||||
assertEquals(0, day(view, "2026-10-24").compressorStarts());
|
||||
assertEquals(1, day(view, "2026-10-25").compressorStarts());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitsAStretchAtLocalMidnightInSummerTime() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-24T21:58:00Z", 100),
|
||||
compressor("2026-10-24T22:03:00Z", 0));
|
||||
|
||||
assertEquals(0.03, day(view, "2026-10-24").compressorHours());
|
||||
assertEquals(0.05, day(view, "2026-10-25").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitsAStretchAtLocalMidnightInWinterTime() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-25T22:58:00Z", 100),
|
||||
compressor("2026-10-25T23:03:00Z", 0));
|
||||
|
||||
assertEquals(0.03, day(view, "2026-10-25").compressorHours());
|
||||
assertEquals(0.05, day(view, "2026-10-26").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theDayOfTheClockChangeLastsTwentyFiveHours() throws IOException {
|
||||
List<HeatPumpSample> samples = new ArrayList<>();
|
||||
Instant at = Instant.parse("2026-10-24T22:00:00Z");
|
||||
while (at.isBefore(Instant.parse("2026-10-25T23:00:00Z"))) {
|
||||
samples.add(compressor(at.toString(), 100));
|
||||
at = at.plusSeconds(300);
|
||||
}
|
||||
|
||||
StatsView view = stats("7d", NOW, samples.toArray(HeatPumpSample[]::new));
|
||||
|
||||
assertEquals(25.0, day(view, "2026-10-25").compressorHours());
|
||||
assertNull(day(view, "2026-10-26").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sumsEachOutputSeparately() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 100.0, 0.0, 0.0, 10.0, 40.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 100.0, 12.0, 38.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, 0.0, null, null));
|
||||
|
||||
Day d = day(view, "2026-10-23");
|
||||
assertEquals(0.17, d.compressorHours());
|
||||
assertEquals(0.08, d.cwuHours());
|
||||
assertEquals(0.08, d.coHours());
|
||||
assertEquals(0.08, d.heaterHours());
|
||||
assertEquals(11.0, d.avgOutdoorTemp());
|
||||
assertEquals(38.0, d.minCwuTemp());
|
||||
assertEquals(40.0, d.maxCwuTemp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void totalsCoverTheWholeRangeWithTheTemperatureOfAllPolls() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
new HeatPumpSample(Instant.parse("2026-10-22T10:00:00Z"), 100.0, 0.0, 0.0, 0.0, 10.0, 30.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 10.0, 31.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 100.0, 4.0, 45.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 4.0, 44.0));
|
||||
|
||||
assertEquals(0.17, view.totals().compressorHours());
|
||||
assertEquals(0.08, view.totals().heaterHours());
|
||||
assertEquals(7.0, view.totals().avgOutdoorTemp());
|
||||
assertEquals(30.0, view.totals().minCwuTemp());
|
||||
assertEquals(45.0, view.totals().maxCwuTemp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingFieldCountsAsOffAndUnknownStateIsNoStart() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), null, null, null, null, null, null),
|
||||
compressor("2026-10-23T10:05:00Z", 100));
|
||||
|
||||
assertEquals(0, day(view, "2026-10-23").compressorStarts());
|
||||
assertNull(day(view, "2026-10-23").avgOutdoorTemp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnknownRange() {
|
||||
assertThrows(IllegalArgumentException.class, () -> stats("24h", NOW));
|
||||
assertThrows(IllegalArgumentException.class, () -> stats(null, NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesRepositoryFailure() {
|
||||
StatsService service = new StatsService((from, to) -> {
|
||||
throw new IOException("influx down");
|
||||
}, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
|
||||
assertThrows(IOException.class, () -> service.stats("7d"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.Forecast.DailyForecast;
|
||||
import home.backend.domain.WeatherView;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class WeatherServiceTest {
|
||||
|
||||
private static final Instant START = Instant.parse("2026-10-24T10:00:00Z");
|
||||
private static final LocalDate TODAY = LocalDate.parse("2026-10-24");
|
||||
|
||||
private Instant now = START;
|
||||
private final Clock clock = new Clock() {
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone) {
|
||||
return Clock.fixed(now, zone);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return now;
|
||||
}
|
||||
};
|
||||
|
||||
private final AtomicInteger forecastCalls = new AtomicInteger();
|
||||
private final AtomicInteger warningCalls = new AtomicInteger();
|
||||
private boolean forecastDown;
|
||||
private boolean warningsDown;
|
||||
private boolean influxDown;
|
||||
private int measuredDays = 5;
|
||||
private List<Warning> warned = List.of(warning(START.plus(Duration.ofHours(8))));
|
||||
|
||||
private final WeatherService service = new WeatherService(
|
||||
() -> {
|
||||
forecastCalls.incrementAndGet();
|
||||
if (forecastDown) {
|
||||
throw new IOException("open-meteo down");
|
||||
}
|
||||
return forecast();
|
||||
},
|
||||
() -> {
|
||||
warningCalls.incrementAndGet();
|
||||
if (warningsDown) {
|
||||
throw new IOException("imgw down");
|
||||
}
|
||||
return warned;
|
||||
},
|
||||
(from, to) -> {
|
||||
if (influxDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
assertEquals(TODAY.minusDays(14), from);
|
||||
assertEquals(TODAY, to);
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
for (int i = 1; i <= measuredDays; i++) {
|
||||
measured.put(TODAY.minusDays(i), 15.0);
|
||||
}
|
||||
return measured;
|
||||
},
|
||||
clock);
|
||||
|
||||
private static Forecast forecast() {
|
||||
Map<LocalDate, DailyForecast> days = new TreeMap<>();
|
||||
for (int i = 1; i <= 14; i++) {
|
||||
days.put(TODAY.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0));
|
||||
}
|
||||
days.put(TODAY, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0));
|
||||
days.put(TODAY.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null));
|
||||
days.put(TODAY.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0));
|
||||
return new Forecast(8.5, days);
|
||||
}
|
||||
|
||||
private static Warning warning(Instant until) {
|
||||
return new Warning("Burza", 2, OffsetDateTime.ofInstant(START, ZoneOffset.UTC),
|
||||
OffsetDateTime.ofInstant(until, ZoneOffset.UTC), "text");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shapesThreeDaysAndEstimatesPvFromTheFactor() throws IOException {
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertEquals(START, view.time());
|
||||
assertEquals(8.5, view.now().temperature());
|
||||
assertEquals(3.0, view.pvFactor(), 1e-9);
|
||||
assertEquals(TODAY, view.today().date());
|
||||
assertEquals("Bezchmurnie", view.today().description());
|
||||
assertEquals(12.34, view.today().tMax());
|
||||
assertEquals(1.5, view.today().sunshineHours());
|
||||
assertEquals(18.0, view.today().pvForecastKwh());
|
||||
assertEquals(TODAY.plusDays(2), view.dayAfter().date());
|
||||
assertEquals("Deszcz", view.tomorrow().description());
|
||||
assertEquals(5.5, view.tomorrow().precipitationMm());
|
||||
assertNull(view.tomorrow().pvForecastKwh());
|
||||
assertEquals(3.0, view.dayAfter().pvForecastKwh());
|
||||
assertEquals("Brak danych", view.dayAfter().description());
|
||||
assertFalse(view.stale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesTheWarsawDateNearMidnight() throws IOException {
|
||||
now = Instant.parse("2026-10-23T22:30:00Z");
|
||||
|
||||
assertEquals(TODAY, service.current().today().date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fewerThanFiveMeasuredDaysGiveNoEstimate() throws IOException {
|
||||
measuredDays = 4;
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertNull(view.pvFactor());
|
||||
assertNull(view.today().pvForecastKwh());
|
||||
assertEquals(12.34, view.today().tMax());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnreachableInfluxGivesNoEstimateButStillTheWeather() throws IOException {
|
||||
influxDown = true;
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertNull(view.pvFactor());
|
||||
assertEquals(0, view.today().weatherCode());
|
||||
assertFalse(view.stale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheForecastFor30MinutesAndWarningsFor10() throws IOException {
|
||||
service.current();
|
||||
now = START.plus(Duration.ofMinutes(9));
|
||||
service.current();
|
||||
assertEquals(1, warningCalls.get());
|
||||
|
||||
now = START.plus(Duration.ofMinutes(10));
|
||||
service.current();
|
||||
assertEquals(2, warningCalls.get());
|
||||
assertEquals(1, forecastCalls.get());
|
||||
|
||||
now = START.plus(Duration.ofMinutes(30));
|
||||
service.current();
|
||||
assertEquals(2, forecastCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheOlderForecastMarkedStaleWhenTheRefreshFails() throws IOException {
|
||||
service.current();
|
||||
forecastDown = true;
|
||||
now = START.plus(Duration.ofMinutes(31));
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertEquals(8.5, view.now().temperature());
|
||||
assertEquals(3.0, view.pvFactor(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheOlderWarningsMarkedStaleWhenTheRefreshFails() throws IOException {
|
||||
service.current();
|
||||
warningsDown = true;
|
||||
now = START.plus(Duration.ofMinutes(11));
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertEquals(1, view.warnings().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnyForecastItFails() {
|
||||
forecastDown = true;
|
||||
|
||||
assertThrows(IOException.class, service::current);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnyWarningsItStillAnswersMarkedStale() throws IOException {
|
||||
warningsDown = true;
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertTrue(view.warnings().isEmpty());
|
||||
assertTrue(view.stale());
|
||||
assertEquals(12.34, view.today().tMax());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropsWarningsThatHaveEnded() throws IOException {
|
||||
warned = List.of(warning(START.plus(Duration.ofMinutes(5))), warning(START.plus(Duration.ofHours(8))));
|
||||
|
||||
assertEquals(2, service.current().warnings().size());
|
||||
|
||||
now = START.plus(Duration.ofMinutes(6));
|
||||
assertEquals(1, service.current().warnings().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aForecastWithoutTodayFails() {
|
||||
var short_ = new WeatherService(() -> new Forecast(null, Map.of()), List::of, (from, to) -> Map.of(), clock);
|
||||
|
||||
assertThrows(IOException.class, short_::current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class WeatherDomainTest {
|
||||
|
||||
private static final LocalDate DAY = LocalDate.parse("2026-10-10");
|
||||
|
||||
@Test
|
||||
void describesWmoCodesInPolish() {
|
||||
assertEquals("Bezchmurnie", WeatherCodes.describe(0));
|
||||
assertEquals("Pochmurno", WeatherCodes.describe(3));
|
||||
assertEquals("Mgła", WeatherCodes.describe(45));
|
||||
assertEquals("Mżawka", WeatherCodes.describe(53));
|
||||
assertEquals("Deszcz", WeatherCodes.describe(63));
|
||||
assertEquals("Śnieg", WeatherCodes.describe(73));
|
||||
assertEquals("Przelotny deszcz", WeatherCodes.describe(81));
|
||||
assertEquals("Burza", WeatherCodes.describe(95));
|
||||
assertEquals("Burza z gradem", WeatherCodes.describe(99));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownOrMissingCodeIsNoData() {
|
||||
assertEquals("Brak danych", WeatherCodes.describe(42));
|
||||
assertEquals("Brak danych", WeatherCodes.describe(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void calibratesAsTotalMeasuredOverTotalRadiation() {
|
||||
var measured = new HashMap<LocalDate, Double>();
|
||||
var radiation = new HashMap<LocalDate, Double>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
measured.put(DAY.plusDays(i), 10.0 + i);
|
||||
radiation.put(DAY.plusDays(i), 5.0 + i);
|
||||
}
|
||||
|
||||
// 60 kWh over 35 MJ/m², not the mean of the daily ratios.
|
||||
assertEquals(60.0 / 35.0, PvCalibration.factor(measured, radiation), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void daysMissingEitherValueDoNotCount() {
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
Map<LocalDate, Double> radiation = new HashMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
measured.put(DAY.plusDays(i), 10.0);
|
||||
radiation.put(DAY.plusDays(i), 5.0);
|
||||
}
|
||||
measured.put(DAY.plusDays(9), 1000.0);
|
||||
radiation.put(DAY.plusDays(8), 1000.0);
|
||||
|
||||
assertEquals(2.0, PvCalibration.factor(measured, radiation), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fewerThanFiveDaysGiveNoFactor() {
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
Map<LocalDate, Double> radiation = new HashMap<>();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
measured.put(DAY.plusDays(i), 10.0);
|
||||
radiation.put(DAY.plusDays(i), 5.0);
|
||||
}
|
||||
|
||||
assertNull(PvCalibration.factor(measured, radiation));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noRadiationGivesNoFactor() {
|
||||
Map<LocalDate, Double> days = new HashMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
days.put(DAY.plusDays(i), 0.0);
|
||||
}
|
||||
|
||||
assertNull(PvCalibration.factor(Map.of(), days));
|
||||
assertNull(PvCalibration.factor(days, days));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entries": [
|
||||
{ "time": "2026-10-24T09:30:00Z", "source": "battery", "what": "Rezerwa baterii", "oldValue": 50, "newValue": 60,
|
||||
"unit": "%", "who": "mariusz", "status": "APPLIED" },
|
||||
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "CWU eko", "oldValue": 45, "newValue": 8,
|
||||
"unit": "°C", "who": "automatycznie (przywrócenie)", "status": "APPLIED" },
|
||||
{ "time": "2026-10-23T18:00:00Z", "source": "battery", "what": "Przedział 3: ładowanie z sieci", "oldValue": 0,
|
||||
"newValue": 1, "unit": "0/1", "who": "mariusz", "status": "FAILED" },
|
||||
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "CO komfort", "oldValue": null, "newValue": 22,
|
||||
"unit": "°C", "who": "mariusz", "status": "REJECTED" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "status": "FAILED", "message": "registers not as written: [167]; not retried", "read": { "167": 80 } }
|
||||
@@ -0,0 +1 @@
|
||||
{ "pct": 60 }
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"slots": [
|
||||
{ "slot": 2, "from": "09:30", "socPct": 90, "gridCharge": true },
|
||||
{ "slot": 3, "socPct": 85 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"stale": false,
|
||||
"socPct": 99.0,
|
||||
"powerW": -24.0,
|
||||
"voltageV": 56.49,
|
||||
"currentA": -0.05,
|
||||
"temperatureC": 19.2,
|
||||
"capacityKwh": null,
|
||||
"today": { "chargeKwh": 0.5, "dischargeKwh": 0.1 },
|
||||
"total": { "chargeKwh": 453.7, "dischargeKwh": 162.1 },
|
||||
"mode": null,
|
||||
"schedule": [],
|
||||
"limits": null,
|
||||
"settingsTime": "2026-10-23T21:00:00Z",
|
||||
"settingsStale": true,
|
||||
"reserve": { "pct": 50, "floorPct": 30 },
|
||||
"editable": true
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"stale": false,
|
||||
"socPct": 99.0,
|
||||
"powerW": -24.0,
|
||||
"voltageV": 56.49,
|
||||
"currentA": -0.05,
|
||||
"temperatureC": 19.2,
|
||||
"capacityKwh": 14.336,
|
||||
"today": { "chargeKwh": 0.5, "dischargeKwh": 0.1 },
|
||||
"total": { "chargeKwh": 453.7, "dischargeKwh": 162.1 },
|
||||
"mode": {
|
||||
"touEnabled": false,
|
||||
"description": "Rezerwa na awarię: bateria nie rozładowuje się na co dzień"
|
||||
},
|
||||
"schedule": [
|
||||
{ "from": "01:00", "to": "05:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
|
||||
{ "from": "05:00", "to": "09:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
|
||||
{ "from": "09:00", "to": "13:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
|
||||
{ "from": "13:00", "to": "17:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
|
||||
{ "from": "17:00", "to": "21:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
|
||||
{ "from": "21:00", "to": "01:00", "powerW": 10000, "socPct": 80, "gridCharge": false }
|
||||
],
|
||||
"limits": {
|
||||
"maxChargeA": 80,
|
||||
"maxDischargeA": 120,
|
||||
"shutdownSocPct": 20,
|
||||
"lowSocPct": 35,
|
||||
"restartSocPct": 50,
|
||||
"gridChargeEnabled": true,
|
||||
"gridChargeCurrentA": 40,
|
||||
"gridChargeStartSocPct": 30
|
||||
},
|
||||
"settingsTime": "2026-10-23T22:26:00Z",
|
||||
"settingsStale": false,
|
||||
"reserve": { "pct": 50, "floorPct": 30 },
|
||||
"editable": true
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
"groups": [
|
||||
{ "title": "Bateria", "items": [
|
||||
{ "label": "Naładowanie", "value": 99.0, "unit": "%", "digits": 0 },
|
||||
{ "label": "Moc", "value": -24.0, "unit": "W", "digits": 0 }
|
||||
{ "label": "Moc", "value": -24.0, "unit": "W", "digits": 0 },
|
||||
{ "label": "Napięcie", "value": 56.5, "unit": "V", "digits": 1 },
|
||||
{ "label": "Prąd", "value": 0.0, "unit": "A", "digits": 1 },
|
||||
{ "label": "Temperatura", "value": 19.2, "unit": "°C", "digits": 1 }
|
||||
] },
|
||||
{ "title": "Sieć", "items": [
|
||||
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
|
||||
@@ -21,7 +24,13 @@
|
||||
{ "label": "Produkcja PV", "value": 29.9, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Zużycie domu", "value": 24.2, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 }
|
||||
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Bateria naładowana", "value": 0.5, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Bateria rozładowana", "value": 0.1, "unit": "kWh", "digits": 1 }
|
||||
] },
|
||||
{ "title": "Od początku", "items": [
|
||||
{ "label": "Bateria naładowana", "value": 454.0, "unit": "kWh", "digits": 0 },
|
||||
{ "label": "Bateria rozładowana", "value": 162.0, "unit": "kWh", "digits": 0 }
|
||||
] }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,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 }
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"range": "7d",
|
||||
"days": [
|
||||
{
|
||||
"date": "2026-10-18",
|
||||
"compressorHours": null,
|
||||
"compressorStarts": null,
|
||||
"cwuHours": null,
|
||||
"coHours": null,
|
||||
"heaterHours": null,
|
||||
"avgOutdoorTemp": null,
|
||||
"minCwuTemp": null,
|
||||
"maxCwuTemp": null
|
||||
},
|
||||
{
|
||||
"date": "2026-10-19",
|
||||
"compressorHours": null,
|
||||
"compressorStarts": null,
|
||||
"cwuHours": null,
|
||||
"coHours": null,
|
||||
"heaterHours": null,
|
||||
"avgOutdoorTemp": null,
|
||||
"minCwuTemp": null,
|
||||
"maxCwuTemp": null
|
||||
},
|
||||
{
|
||||
"date": "2026-10-20",
|
||||
"compressorHours": null,
|
||||
"compressorStarts": null,
|
||||
"cwuHours": null,
|
||||
"coHours": null,
|
||||
"heaterHours": null,
|
||||
"avgOutdoorTemp": null,
|
||||
"minCwuTemp": null,
|
||||
"maxCwuTemp": null
|
||||
},
|
||||
{
|
||||
"date": "2026-10-21",
|
||||
"compressorHours": null,
|
||||
"compressorStarts": null,
|
||||
"cwuHours": null,
|
||||
"coHours": null,
|
||||
"heaterHours": null,
|
||||
"avgOutdoorTemp": null,
|
||||
"minCwuTemp": null,
|
||||
"maxCwuTemp": null
|
||||
},
|
||||
{
|
||||
"date": "2026-10-22",
|
||||
"compressorHours": null,
|
||||
"compressorStarts": null,
|
||||
"cwuHours": null,
|
||||
"coHours": null,
|
||||
"heaterHours": null,
|
||||
"avgOutdoorTemp": null,
|
||||
"minCwuTemp": null,
|
||||
"maxCwuTemp": null
|
||||
},
|
||||
{
|
||||
"date": "2026-10-23",
|
||||
"compressorHours": 0.17,
|
||||
"compressorStarts": 1,
|
||||
"cwuHours": 0.08,
|
||||
"coHours": 0.0,
|
||||
"heaterHours": 0.08,
|
||||
"avgOutdoorTemp": 8.6,
|
||||
"minCwuTemp": 43.0,
|
||||
"maxCwuTemp": 46.0
|
||||
},
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"compressorHours": null,
|
||||
"compressorStarts": null,
|
||||
"cwuHours": null,
|
||||
"coHours": null,
|
||||
"heaterHours": null,
|
||||
"avgOutdoorTemp": null,
|
||||
"minCwuTemp": null,
|
||||
"maxCwuTemp": null
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"compressorHours": 0.17,
|
||||
"compressorStarts": 1,
|
||||
"cwuHours": 0.08,
|
||||
"coHours": 0.0,
|
||||
"heaterHours": 0.08,
|
||||
"avgOutdoorTemp": 8.6,
|
||||
"minCwuTemp": 43.0,
|
||||
"maxCwuTemp": 46.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"time": "2026-10-23T22:14:00Z",
|
||||
"today": {"date": "2026-10-24", "weatherCode": 3, "description": "Pochmurno", "tMax": 10.9, "tMin": 3.8,
|
||||
"precipitationMm": 0.0, "sunshineHours": 2.0, "pvForecastKwh": 18.0},
|
||||
"tomorrow": {"date": "2026-10-25", "weatherCode": 51, "description": "Mżawka", "tMax": 13.2, "tMin": 4.0,
|
||||
"precipitationMm": 0.3, "sunshineHours": 7.9, "pvForecastKwh": 9.0},
|
||||
"dayAfter": {"date": "2026-10-26", "weatherCode": 61, "description": "Słaby deszcz", "tMax": 9.6, "tMin": 4.1,
|
||||
"precipitationMm": 8.4, "sunshineHours": 0.0, "pvForecastKwh": 4.2},
|
||||
"now": {"temperature": 10.6},
|
||||
"warnings": [{"event": "Silny wiatr", "level": 2, "from": "2026-10-25T08:00:00+01:00",
|
||||
"to": "2026-10-25T18:00:00+01:00", "text": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h."}],
|
||||
"pvFactor": 2.0,
|
||||
"stale": false
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"results":[{"statement_id":0,"series":[{"name":"deye_settings_audit","columns":["time","new_value","old_value","register","requested_by","status"],"values":[[1761298200000,60,50,"reserve","mariusz","APPLIED"],[1761242400000,1,0,"174","mariusz","FAILED"]]}]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"results":[{"statement_id":0,"series":[{"name":"setpoint_audit","columns":["time","new_value","old_value","register","requested_by","setpoint","status"],"values":[[1761292800000,8,45,4143,"restore","cwu_economy","APPLIED"],[1761202800000,22,null,4146,"mariusz","co_comfort","REJECTED"]]}]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"results":[{"statement_id":0,"series":[{"name":"heat_pump","columns":["time","output_compressor","output_cwu","output_co","output_heater","outdoor_temp","cwu_temp"],"values":[[1792771200000,0,0,0,0,8.5,44.2],[1792771500000,100,100,0,0,8.5,43.9],[1792771800000,50,0,100,100,null,null]]}]}]}
|
||||
@@ -0,0 +1,80 @@
|
||||
[
|
||||
{
|
||||
"id": "Sk20261024094211181",
|
||||
"nazwa_zdarzenia": "Silny wiatr",
|
||||
"stopien": "2",
|
||||
"prawdopodobienstwo": "75",
|
||||
"obowiazuje_do": "2026-10-25 18:00:00",
|
||||
"obowiazuje_od": "2026-10-25 08:00:00",
|
||||
"opublikowano": "2026-10-24 11:42:00",
|
||||
"tresc": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h, w porywach do 90 km/h, z południa.",
|
||||
"komentarz": "Brak.",
|
||||
"biuro": "Centralne Biuro Prognoz Meteorologicznych w Warszawie",
|
||||
"teryt": [
|
||||
"0221",
|
||||
"0207",
|
||||
"0206",
|
||||
"0212",
|
||||
"0261",
|
||||
"0265",
|
||||
"1862"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Sk20261024094212544",
|
||||
"nazwa_zdarzenia": "Silny wiatr",
|
||||
"stopien": "1",
|
||||
"prawdopodobienstwo": "75",
|
||||
"obowiazuje_do": "2026-10-25 18:00:00",
|
||||
"obowiazuje_od": "2026-10-25 08:00:00",
|
||||
"opublikowano": "2026-10-24 11:42:00",
|
||||
"tresc": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 40 km/h, w porywach do 85 km/h, z południa.",
|
||||
"komentarz": "Brak.",
|
||||
"biuro": "Centralne Biuro Prognoz Meteorologicznych w Warszawie",
|
||||
"teryt": [
|
||||
"1807"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Sk20261024094213861",
|
||||
"nazwa_zdarzenia": "Silny wiatr",
|
||||
"stopien": "1",
|
||||
"prawdopodobienstwo": "75",
|
||||
"obowiazuje_do": "2026-10-25 19:00:00",
|
||||
"obowiazuje_od": "2026-10-25 12:00:00",
|
||||
"opublikowano": "2026-10-24 11:42:00",
|
||||
"tresc": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 40 km/h, w porywach do 75 km/h, z południa.",
|
||||
"komentarz": "Brak.",
|
||||
"biuro": "Centralne Biuro Prognoz Meteorologicznych w Warszawie",
|
||||
"teryt": [
|
||||
"2804",
|
||||
"2861",
|
||||
"2802"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "Sk20261024094216930",
|
||||
"nazwa_zdarzenia": "Silny wiatr",
|
||||
"stopien": "1",
|
||||
"prawdopodobienstwo": "80",
|
||||
"obowiazuje_do": "2026-10-25 21:00:00",
|
||||
"obowiazuje_od": "2026-10-25 10:00:00",
|
||||
"opublikowano": "2026-10-24 11:42:00",
|
||||
"tresc": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 40 km/h, w porywach do 80 km/h, z południa.",
|
||||
"komentarz": "Brak.",
|
||||
"biuro": "Centralne Biuro Prognoz Meteorologicznych w Warszawie",
|
||||
"teryt": [
|
||||
"1205",
|
||||
"2402",
|
||||
"1210",
|
||||
"1217",
|
||||
"1207",
|
||||
"2461",
|
||||
"2403",
|
||||
"1211",
|
||||
"1215",
|
||||
"1262",
|
||||
"2417"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{"latitude":49.68106,"longitude":21.75624,"generationtime_ms":0.3905296325683594,"utc_offset_seconds":7200,"timezone":"Europe/Warsaw","timezone_abbreviation":"GMT+2","elevation":282.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C"},"current":{"time":"2026-10-24T16:45","interval":900,"temperature_2m":10.6},"daily_units":{"time":"iso8601","weather_code":"wmo code","temperature_2m_max":"°C","temperature_2m_min":"°C","precipitation_sum":"mm","shortwave_radiation_sum":"MJ/m²","sunshine_duration":"s"},"daily":{"time":["2026-10-10","2026-10-11","2026-10-12","2026-10-13","2026-10-14","2026-10-15","2026-10-16","2026-10-17","2026-10-18","2026-10-19","2026-10-20","2026-10-21","2026-10-22","2026-10-23","2026-10-24","2026-10-25","2026-10-26"],"weather_code":[3,63,3,3,51,51,3,51,3,3,3,3,63,3,3,51,61],"temperature_2m_max":[17.8,14.4,12.6,10.3,13.3,11.9,12.8,11.0,13.0,10.6,15.5,19.1,11.1,13.5,10.9,13.2,9.6],"temperature_2m_min":[8.7,8.8,5.1,3.3,0.8,5.5,4.6,3.0,6.8,6.6,7.8,7.3,8.4,4.6,3.8,4.0,4.1],"precipitation_sum":[0.00,15.00,0.00,0.00,0.50,0.30,0.00,0.10,0.00,0.00,0.00,0.00,27.70,0.00,0.00,0.30,8.40],"shortwave_radiation_sum":[9.44,3.23,10.22,6.92,8.53,5.71,9.71,4.36,9.99,2.90,8.72,10.19,0.20,10.33,6.88,8.92,2.10],"sunshine_duration":[14400.00,0.00,12427.89,0.00,21009.37,355.43,7848.32,0.00,11931.89,0.00,11020.14,33520.17,0.00,34772.52,7151.32,28314.23,0.00]}}
|
||||
@@ -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.
|
||||
+2
-1
@@ -39,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) and 15 min leases the backend renews (ADR-011); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
|
||||
| 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 |
|
||||
|
||||
|
||||
+16
-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.
|
||||
@@ -74,26 +74,35 @@ 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.
|
||||
- F2 History for the last 7 days.
|
||||
heater, alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
|
||||
pressure) of the living room.
|
||||
- F2 History for the last 7 days, and per-day heat pump statistics for 7 or 30 days (compressor, CWU, CO and heater hours,
|
||||
starts "at least" because polls are 5 minutes apart).
|
||||
- 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–48 °C (decided; the gateway's ceiling stays 50), CO 5–25 °C.
|
||||
- **Boost**: raise the economy temperature for a duration the user picks; the previous value
|
||||
is restored automatically.
|
||||
- **Away / not needed**: lower the comfort temperature (e.g. CWU 10 °C, CO 15 °C) until changed back.
|
||||
- **Lease** (ADR-011): every change also needs the backend to renew it every 5 minutes; after
|
||||
- **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; a details panel per device (Deye, Fronius, house meter).
|
||||
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals; a details panel per device (Deye, Fronius, house meter); a battery view with SOC, power, the Deye's program schedule and limits.
|
||||
|
||||
- F8 Battery settings (ADR-013, off until enabled): change the Deye's Time of Use switch and the six slots (start, SOC, grid charge) from the Bateria view, and the app's backup reserve (default 50 %, floor 30 %) that every enabled slot SOC must hold. Refused when the settings reading is older than 30 min or changed meanwhile; read back, no restore.
|
||||
|
||||
- F9 Weather and warnings: a 3-day forecast (Open-Meteo) with a PV estimate calibrated on the last 14 measured days, and IMGW warnings for Krosno, as a card and banners on Podgląd; a storm or wind warning hints on Bateria to keep the battery full. Read-only, from the backend in Kraków.
|
||||
|
||||
Safety, for every write:
|
||||
|
||||
- S1 Only allowlisted registers, each with min, max and step.
|
||||
- S2 Read back and verify after writing.
|
||||
- S3 Audit log: who, when, register, old and new value.
|
||||
- S3 Audit log: who, when, register, old and new value; shown in the app as the change history.
|
||||
- S4 Rate limit per register.
|
||||
- S5 Tests and development never write to a real device or the production database.
|
||||
- S6 The gateway accepts commands only from the backend: a shared token, and a firewall rule on
|
||||
|
||||
@@ -16,3 +16,26 @@ 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).
|
||||
|
||||
Ustawienia ends with the "Historia zmian" card (`ChangeHistory.tsx`): the last 7 days of `api/audit`, 20 rows and "Pokaż więcej".
|
||||
|
||||
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).
|
||||
|
||||
Podgląd shows the "Pogoda" card (3 days, PV estimate) and one banner per IMGW warning from `api/weather`; Bateria hints to keep the battery full on a storm or strong-wind warning (a hint only, nothing changes by itself).
|
||||
|
||||
Historia has two views in the hash, like Energia: `#history` (Wykresy) and `#history/stats` (Statystyki: totals and a daily chart of
|
||||
compressor hours from `api/stats`, 7 or 30 days). `SubView.tsx` holds the shared tab switch.
|
||||
|
||||
## Install on a phone (PWA)
|
||||
|
||||
The build has a manifest (`public/`), icons and a service worker (`src/sw.js`, written to `dist/sw.js` with the list of built
|
||||
files by `precachePlugin` in `vite.config.ts`). The shell opens offline; `/api/` is never cached. nginx sends `sw.js` and
|
||||
`index.html` with `no-cache`, so an update is picked up at the next open.
|
||||
|
||||
- Android (Chrome): menu, "Zainstaluj aplikację" / "Dodaj do ekranu głównego".
|
||||
- iPhone (Safari): Share, "Dodaj do ekranu początkowego". It must be Safari, and over https.
|
||||
+7
-1
@@ -6,7 +6,13 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#0e2a21">
|
||||
<title>Dom Szczepańcowa</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="icon" href="./icon-192.png">
|
||||
<link rel="apple-touch-icon" href="./apple-touch-icon.png">
|
||||
<link rel="manifest" href="./manifest.webmanifest">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Pompa">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -13,7 +13,7 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# The same hardening the backend sends with the API (SecurityHeaders.java).
|
||||
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; manifest-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
add_header Cache-Control $cache_control;
|
||||
@@ -25,6 +25,11 @@ server {
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# nginx's mime.types does not know .webmanifest.
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Pompa ciepła – Szczepańcowa",
|
||||
"short_name": "Pompa",
|
||||
"lang": "pl",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"theme_color": "#0e2a21",
|
||||
"background_color": "#f2f6f3",
|
||||
"icons": [
|
||||
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
+10
-8
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useState, type ComponentType } from "react";
|
||||
import { celsius, hour, longDate } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { Energy } from "./Energy";
|
||||
import { History } from "./History";
|
||||
import { Overview } from "./Overview";
|
||||
import { usePoll } from "./poll";
|
||||
import { Settings } from "./Settings";
|
||||
import { Status } from "./Status";
|
||||
import { useTheme, type Theme } from "./theme";
|
||||
import type { HeatPump } from "./types";
|
||||
import { Dot } from "./ui";
|
||||
import { Dot, Loading } from "./ui";
|
||||
|
||||
// Energy shares code with the first screen; the others load when opened.
|
||||
const History = lazy(() => import("./History").then((m) => ({ default: m.History })));
|
||||
const Settings = lazy(() => import("./Settings").then((m) => ({ default: m.Settings })));
|
||||
const Status = lazy(() => import("./Status").then((m) => ({ default: m.Status })));
|
||||
|
||||
const SCREENS = {
|
||||
overview: { label: "Podgląd", icon: "home", view: Overview },
|
||||
@@ -17,11 +19,11 @@ const SCREENS = {
|
||||
history: { label: "Historia", icon: "chart", view: History },
|
||||
settings: { label: "Ustawienia", icon: "sliders", view: Settings },
|
||||
status: { label: "Stan", icon: "check", view: Status },
|
||||
} satisfies Record<string, { label: string; icon: IconName; view: () => unknown }>;
|
||||
} satisfies Record<string, { label: string; icon: IconName; view: ComponentType }>;
|
||||
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";
|
||||
};
|
||||
|
||||
@@ -111,7 +113,7 @@ export function App() {
|
||||
<div className="hidden flex-1 lg:flex lg:flex-col"><SideFooter /></div>
|
||||
</nav>
|
||||
<main className="mx-auto max-w-[1500px] px-4 pt-5 pb-28 sm:px-6 lg:px-8 lg:pt-8 lg:pb-10">
|
||||
<View />
|
||||
<Suspense fallback={<Loading />}><View /></Suspense>
|
||||
<ThemeSwitch className="mx-auto mt-8 max-w-xs border border-edge bg-panel text-ink lg:hidden" />
|
||||
</main>
|
||||
</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,123 @@
|
||||
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";
|
||||
import { threatensPower, useWeather } from "./Weather";
|
||||
|
||||
/** 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);
|
||||
const weather = useWeather();
|
||||
if (!b) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
return (
|
||||
<>
|
||||
<Freshness offline={offline} time={b.time} stale={b.stale} />
|
||||
{weather?.warnings.some(threatensPower) && (
|
||||
<Banner tone="warn">Ostrzeżenie IMGW: rozważ trzymanie baterii pełnej (rezerwa na awarię).</Banner>
|
||||
)}
|
||||
{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,69 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { Audit, AuditEntry } from "./types";
|
||||
|
||||
// A fresh module per test: the poller keeps its answers between screens, not between tests.
|
||||
const history = async () => (await import("./ChangeHistory")).ChangeHistory;
|
||||
|
||||
const entry = (over: Partial<AuditEntry>): AuditEntry => ({
|
||||
time: "2026-10-23T18:00:00Z", source: "battery", what: "Przedział 2: start", oldValue: 1800, newValue: 2130,
|
||||
unit: "HHMM", who: "mariusz", status: "APPLIED", ...over,
|
||||
});
|
||||
|
||||
describe("ChangeHistory", () => {
|
||||
it("lists the changes with what, old and new value, who and a pill for a failed one", async () => {
|
||||
serve({ "api/audit": example<Audit>("audit") });
|
||||
const ChangeHistory = await history();
|
||||
render(<ChangeHistory />);
|
||||
|
||||
const rows = await screen.findAllByRole("listitem");
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(within(rows[0]).getByText("Rezerwa baterii")).toBeInTheDocument();
|
||||
expect(rows[0]).toHaveTextContent("50 % → 60 %");
|
||||
expect(rows[1]).toHaveTextContent("45 °C → 8 °C");
|
||||
expect(rows[1]).toHaveTextContent("automatycznie (przywrócenie)");
|
||||
expect(rows[2]).toHaveTextContent("nie → tak");
|
||||
expect(within(rows[2]).getByText("nieudana")).toBeInTheDocument();
|
||||
expect(rows[3]).toHaveTextContent("— → 22 °C");
|
||||
expect(within(rows[3]).getByText("odrzucona")).toBeInTheDocument();
|
||||
expect(within(rows[0]).queryByText(/nieudana|odrzucona|konflikt|nieważna/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a clock time as HH:MM", async () => {
|
||||
serve({ "api/audit": { entries: [entry({})] } });
|
||||
const ChangeHistory = await history();
|
||||
render(<ChangeHistory />);
|
||||
|
||||
expect(await screen.findByRole("listitem")).toHaveTextContent("18:00 → 21:30");
|
||||
});
|
||||
|
||||
it("says when nothing changed", async () => {
|
||||
serve({ "api/audit": { entries: [] } });
|
||||
const ChangeHistory = await history();
|
||||
render(<ChangeHistory />);
|
||||
|
||||
expect(await screen.findByText("Brak zmian w ostatnich 7 dniach")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 20 changes and the rest on request", async () => {
|
||||
const entries = Array.from({ length: 25 }, (_, i) => entry({ time: `2026-10-23T${String(i).padStart(2, "0")}:00:00Z` }));
|
||||
serve({ "api/audit": { entries } });
|
||||
const ChangeHistory = await history();
|
||||
render(<ChangeHistory />);
|
||||
|
||||
expect(await screen.findAllByRole("listitem")).toHaveLength(20);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Pokaż więcej (5)" }));
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(25);
|
||||
expect(screen.queryByRole("button", { name: /Pokaż więcej/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("says the history is unavailable when the backend does not answer", async () => {
|
||||
serve({});
|
||||
const ChangeHistory = await history();
|
||||
render(<ChangeHistory />);
|
||||
|
||||
expect(await screen.findByText("Historia chwilowo niedostępna.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user