Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
655e84be8d | ||
|
|
4cb8bfd27b | ||
|
|
bdea2a177d | ||
|
|
d8b06e2a89 | ||
|
|
1405695da5 | ||
|
|
ea2862ae04 | ||
|
|
317950cb75 | ||
|
|
8423d723a1 | ||
|
|
cb93f24365 | ||
|
|
f181d0bbe3 | ||
|
|
f57c3adc58 | ||
|
|
56a457085e | ||
|
|
6dcbb5b8e4 | ||
|
|
ace189f5e2 | ||
|
|
d356fb2a2c | ||
|
|
0fb139192d | ||
|
|
40823683be | ||
|
|
1f43987db5 | ||
|
|
e88c28899f | ||
|
|
d5ab64aa61 | ||
|
|
01c5e1efe5 | ||
|
|
0819ed8261 | ||
|
|
338befaffa | ||
|
|
378f4f6623 | ||
|
|
82670eb4a1 | ||
|
|
d07be384a1 | ||
|
|
860269a353 | ||
|
|
e4481eeeae | ||
|
|
e1e50eb7c2 | ||
|
|
c7d88e6c0b | ||
|
|
186c1984c0 | ||
|
|
83d9e1ecfc | ||
|
|
cbf507fa18 | ||
|
|
a99f2b677a | ||
|
|
928915d059 | ||
|
|
c0c46973e8 | ||
|
|
64e1e6ffec | ||
|
|
b288c98106 | ||
|
|
e5e13cedb5 | ||
|
|
a594512f33 | ||
|
|
a5ba9e8756 | ||
|
|
619c3ee990 | ||
|
|
8111405326 | ||
|
|
965dfae644 | ||
|
|
2da7246fe4 | ||
|
|
a4dc88cbbc | ||
|
|
a01c507a6e |
No files matched your search
@@ -8,6 +8,39 @@ The records in `home.backend.domain` and the examples in `src/test/resources/api
|
||||
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
|
||||
the three values are null (`pi-sensor-stale.json`).
|
||||
|
||||
`GET /api/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.
|
||||
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`.
|
||||
@@ -20,6 +53,8 @@ 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 |
|
||||
| `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
|
||||
|
||||
@@ -4,24 +4,39 @@ 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);
|
||||
/** Warnings are optional, so a slow IMGW must not hold the forecast. */
|
||||
private static final Duration WARNINGS_TIMEOUT = Duration.ofSeconds(3);
|
||||
|
||||
private Backend() {
|
||||
}
|
||||
|
||||
@@ -32,14 +47,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 PiSensorService(gateway, clock));
|
||||
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, WARNINGS_TIMEOUT),
|
||||
new InfluxPvEnergy(influx), clock);
|
||||
}
|
||||
|
||||
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
|
||||
|
||||
@@ -5,7 +5,11 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayBattery;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView.Reserve;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
@@ -18,10 +22,11 @@ import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */
|
||||
public class GatewayHttpCommands implements GatewayCommands {
|
||||
/** Sends commands to the gateway's {@code /commands} and {@code /battery} with the shared token (S6). */
|
||||
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
|
||||
|
||||
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
|
||||
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
|
||||
@@ -29,6 +34,20 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
/** Restore now: 200 restored, 404 nothing pending, 409 changed meanwhile, 429 too soon, 502 failed. */
|
||||
private static final Set<Integer> RESTORE_VERDICTS = Set.of(200, 404, 409, 429, 502);
|
||||
|
||||
/** Battery change: 200 applied, 422 rejected, 409 stale or changed meanwhile, 429 too soon, 502 failed. */
|
||||
private static final Set<Integer> BATTERY_VERDICTS = Set.of(200, 422, 409, 429, 502);
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record ScheduleRequest(Boolean enabled, List<SlotRequest> slots) {
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record SlotRequest(int slot, Integer start, Integer soc, Boolean gridCharge) {
|
||||
}
|
||||
|
||||
private record ReserveRequest(int pct) {
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record Request(String setpoint, int value, Long minutes, Instant until) {
|
||||
}
|
||||
@@ -47,6 +66,8 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
private final URI endpoint;
|
||||
private final URI renewEndpoint;
|
||||
private final URI restoreEndpoint;
|
||||
private final URI reserveEndpoint;
|
||||
private final URI scheduleEndpoint;
|
||||
private final String token;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
@@ -56,6 +77,8 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
this.endpoint = baseUri.resolve("/commands");
|
||||
this.renewEndpoint = baseUri.resolve("/commands/renew");
|
||||
this.restoreEndpoint = baseUri.resolve("/commands/restore");
|
||||
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
|
||||
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
|
||||
this.token = token;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
@@ -90,6 +113,30 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Reserve reserve() throws IOException {
|
||||
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
|
||||
var slots = change.slots().stream().map(slot -> new SlotRequest(slot.slot(),
|
||||
slot.from() == null ? null : slot.from().getHour() * 100 + slot.from().getMinute(),
|
||||
slot.socPct(), slot.gridCharge())).toList();
|
||||
return battery(scheduleEndpoint, new ScheduleRequest(change.enabled(), slots), requestedBy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
|
||||
return battery(reserveEndpoint, new ReserveRequest(pct), requestedBy);
|
||||
}
|
||||
|
||||
private BatteryResult battery(URI uri, Object body, String requestedBy) throws IOException {
|
||||
var request = request(uri).header("Content-Type", "application/json").header("X-Requested-By", requestedBy)
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(JSON.writeValueAsBytes(body)));
|
||||
return JSON.readValue(send(request, BATTERY_VERDICTS), BatteryResult.class);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request() throws IOException {
|
||||
return request(endpoint);
|
||||
}
|
||||
@@ -103,14 +150,15 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
|
||||
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
|
||||
try {
|
||||
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
|
||||
var built = request.build();
|
||||
var response = http.send(built, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (!accepted.contains(response.statusCode())) {
|
||||
throw new IOException("gateway /commands answered " + response.statusCode());
|
||||
throw new IOException("gateway " + built.uri().getPath() + " answered " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted while calling gateway /commands", e);
|
||||
throw new IOException("interrupted while calling the gateway", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,19 @@ 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;
|
||||
@@ -42,6 +48,9 @@ public class ApiServer {
|
||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
|
||||
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
|
||||
|
||||
private record ReserveRequest(Integer pct) {
|
||||
}
|
||||
|
||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
||||
}
|
||||
|
||||
@@ -58,6 +67,7 @@ public class ApiServer {
|
||||
}
|
||||
|
||||
private static final String UNREACHABLE = "gateway unreachable";
|
||||
private static final String UNAVAILABLE = "battery changes are not available: gateway unreachable or not enabled";
|
||||
private static final String WRITE_MARKER = "home-pump";
|
||||
private static final String PRESETS_PATH = "/api/presets/";
|
||||
private static final String SETPOINTS_PATH = "/api/setpoints/";
|
||||
@@ -79,7 +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, PiSensorService piSensor) 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).
|
||||
@@ -91,9 +103,17 @@ 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)))));
|
||||
@@ -208,6 +228,37 @@ public class ApiServer {
|
||||
}
|
||||
}
|
||||
|
||||
private static void postSchedule(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
|
||||
try {
|
||||
var change = JSON.readValue(body, BatteryChange.class);
|
||||
if (change == null) {
|
||||
throw new IllegalArgumentException("a change is required");
|
||||
}
|
||||
var result = battery.changeSchedule(change, user(exchange));
|
||||
send(exchange, batteryHttpStatus(result.status()), result);
|
||||
} catch (JsonProcessingException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"enabled\"?, \"slots\": [{\"slot\", \"from\"?: \"HH:MM\", "
|
||||
+ "\"socPct\"?, \"gridCharge\"?}]}"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNAVAILABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postReserve(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
|
||||
try {
|
||||
var request = JSON.readValue(body, ReserveRequest.class);
|
||||
if (request == null || request.pct() == null) {
|
||||
throw new IllegalArgumentException("pct is required");
|
||||
}
|
||||
var result = battery.changeReserve(request.pct(), user(exchange));
|
||||
send(exchange, batteryHttpStatus(result.status()), result);
|
||||
} catch (JsonProcessingException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"pct\"}"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNAVAILABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
|
||||
throws IOException {
|
||||
try {
|
||||
@@ -247,6 +298,16 @@ public class ApiServer {
|
||||
};
|
||||
}
|
||||
|
||||
private static int batteryHttpStatus(BatteryResult.Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
case REJECTED -> 422;
|
||||
case STALE -> 409;
|
||||
case TOO_SOON -> 429;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
|
||||
return switch (outcome) {
|
||||
case RESTORED -> 200;
|
||||
@@ -283,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 4, 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=4");
|
||||
}
|
||||
|
||||
@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", "Ciepła woda – oszczędnie", "cwu_comfort", "Ciepła woda – komfort",
|
||||
"co_economy", "Ogrzewanie – oszczędnie", "co_comfort", "Ogrzewanie – 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,114 @@
|
||||
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.
|
||||
* No schedule at all when a start is missing. */
|
||||
private static List<Slot> schedule(Map<String, Double> s) {
|
||||
boolean complete = IntStream.rangeClosed(1, SLOTS).allMatch(n -> whole(s, "prog" + n + "_start_hhmm") != null);
|
||||
if (!complete) {
|
||||
return List.of();
|
||||
}
|
||||
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,80 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* One remote answer kept for a while, and no longer than its local day when a zone is given. When a refresh
|
||||
* fails the older answer is served marked stale, and the source is left alone for a short back-off so a
|
||||
* source that is down does not make every request wait for its timeout.
|
||||
*/
|
||||
final class Cached<T> {
|
||||
|
||||
interface Fetch<T> {
|
||||
T fetch() throws IOException;
|
||||
}
|
||||
|
||||
record Entry<T>(T value, boolean stale) {
|
||||
}
|
||||
|
||||
private static final Duration BACK_OFF = Duration.ofSeconds(60);
|
||||
|
||||
private final Duration ttl;
|
||||
private final ZoneId dayZone;
|
||||
private final Clock clock;
|
||||
private final Fetch<T> source;
|
||||
private T value;
|
||||
private Instant fetchedAt;
|
||||
private Instant failedAt;
|
||||
|
||||
Cached(Duration ttl, Clock clock, Fetch<T> source) {
|
||||
this(ttl, null, clock, source);
|
||||
}
|
||||
|
||||
/** @param dayZone the answer expires at midnight of this zone even before the ttl, or null */
|
||||
Cached(Duration ttl, ZoneId dayZone, Clock clock, Fetch<T> source) {
|
||||
this.ttl = ttl;
|
||||
this.dayZone = dayZone;
|
||||
this.clock = clock;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/** @throws IOException when the refresh fails or is backing off, and there is nothing older to serve */
|
||||
synchronized Entry<T> get() throws IOException {
|
||||
Instant now = clock.instant();
|
||||
if (value != null && now.isBefore(expiry())) {
|
||||
return new Entry<>(value, false);
|
||||
}
|
||||
if (failedAt != null && now.isBefore(failedAt.plus(BACK_OFF))) {
|
||||
return older(new IOException("source failed recently, not retrying yet"));
|
||||
}
|
||||
try {
|
||||
value = source.fetch();
|
||||
fetchedAt = now;
|
||||
failedAt = null;
|
||||
return new Entry<>(value, false);
|
||||
} catch (IOException e) {
|
||||
failedAt = now;
|
||||
return older(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Entry<T> older(IOException failure) throws IOException {
|
||||
if (value == null) {
|
||||
throw failure;
|
||||
}
|
||||
return new Entry<>(value, true);
|
||||
}
|
||||
|
||||
private Instant expiry() {
|
||||
Instant byTtl = fetchedAt.plus(ttl);
|
||||
if (dayZone == null) {
|
||||
return byTtl;
|
||||
}
|
||||
Instant midnight = fetchedAt.atZone(dayZone).toLocalDate().plusDays(1).atStartOfDay(dayZone).toInstant();
|
||||
return midnight.isBefore(byTtl) ? midnight : byTtl;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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), ZONE, 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) {
|
||||
}
|
||||
}
|
||||
@@ -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,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,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) {
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,11 @@ package home.backend.adapters.gateway;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
@@ -19,6 +23,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -60,16 +65,20 @@ class GatewayHttpCommandsTest {
|
||||
}
|
||||
|
||||
private void answerCommands() {
|
||||
server.createContext("/commands", exchange -> {
|
||||
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
|
||||
exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
for (String path : List.of("/commands", "/battery")) {
|
||||
server.createContext(path, this::answer);
|
||||
}
|
||||
}
|
||||
|
||||
private void answer(HttpExchange exchange) throws IOException {
|
||||
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
|
||||
exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
private GatewayHttpCommands commands(String token) {
|
||||
@@ -216,6 +225,79 @@ class GatewayHttpCommandsTest {
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheBatteryReserveWithTheToken() throws Exception {
|
||||
serve(200, contract("battery-reserve.json"));
|
||||
|
||||
var reserve = commands("s3cret-token").reserve();
|
||||
|
||||
assertEquals(new BatteryView.Reserve(50, 30), reserve);
|
||||
assertEquals("GET", seen.getFirst().method());
|
||||
assertEquals("/battery/reserve", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsAScheduleChangeAsTheGatewayExpectsIt() throws Exception {
|
||||
serve(200, contract("response-battery-applied.json"));
|
||||
var change = new BatteryChange(true, List.of(new BatteryChange.Slot(2, LocalTime.of(9, 30), 90, true),
|
||||
new BatteryChange.Slot(3, null, 85, null)));
|
||||
|
||||
var result = commands("s3cret-token").changeSchedule(change, "anna");
|
||||
|
||||
assertEquals(BatteryResult.Status.APPLIED, result.status());
|
||||
assertEquals(java.util.Map.of(167, 90, 172, 1), result.read());
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/battery/schedule", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals("anna", seen.getFirst().requestedBy());
|
||||
assertEquals(contractJson("request-battery-schedule.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsAReserveChange() throws Exception {
|
||||
serve(200, contract("response-battery-applied.json"));
|
||||
|
||||
commands("s3cret-token").changeReserve(60, "anna");
|
||||
|
||||
assertEquals("/battery/reserve", seen.getFirst().path());
|
||||
assertEquals(contractJson("{\"pct\":60}"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyBatteryStatusOfTheGatewayIsAVerdict() throws Exception {
|
||||
var expected = java.util.Map.of(422, BatteryResult.Status.REJECTED, 409, BatteryResult.Status.STALE,
|
||||
429, BatteryResult.Status.TOO_SOON, 502, BatteryResult.Status.FAILED);
|
||||
for (var entry : expected.entrySet()) {
|
||||
serve(entry.getKey(), "{\"status\":\"" + entry.getValue() + "\",\"message\":\"x\",\"read\":{}}");
|
||||
|
||||
assertEquals(entry.getValue(), commands("s3cret-token").changeReserve(60, "anna").status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedBatteryWriteCarriesWhatWasRead() throws Exception {
|
||||
serve(502, contract("response-battery-failed.json"));
|
||||
|
||||
var result = commands("s3cret-token").changeSchedule(new BatteryChange(null, null), "anna");
|
||||
|
||||
assertEquals(BatteryResult.Status.FAILED, result.status());
|
||||
assertEquals(java.util.Map.of(167, 80), result.read());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryCallsFailWithoutAVerdictOrToken() {
|
||||
for (int status : List.of(400, 401, 404, 503)) {
|
||||
serve(status, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").reserve(), "GET " + status);
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").changeReserve(60, "anna"), "POST " + status);
|
||||
}
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).reserve());
|
||||
assertThrows(IOException.class, () -> commands(null).changeReserve(60, "anna"));
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
|
||||
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
|
||||
|
||||
@@ -2,12 +2,19 @@ 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;
|
||||
@@ -19,11 +26,17 @@ 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;
|
||||
@@ -39,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;
|
||||
@@ -58,12 +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;
|
||||
|
||||
@@ -87,6 +108,16 @@ 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;
|
||||
@@ -131,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 PiSensorService(gateway, clock));
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -144,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,
|
||||
@@ -155,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))),
|
||||
@@ -171,11 +264,29 @@ class ApiServerTest {
|
||||
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
|
||||
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
|
||||
"frequency", 50.01, "import_energy_active", 67514.27), energyTime))),
|
||||
new DeviceSnapshot("deye-settings", true, energyTime, null,
|
||||
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
|
||||
new DeviceSnapshot("bme280", true, energyTime, null,
|
||||
List.of(new Measurement("measurement", "MEASUREMENT",
|
||||
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8), energyTime)))));
|
||||
}
|
||||
|
||||
/** The settings read from the inverter on 2026-10-24: time of use off, six 4-hour slots. */
|
||||
private static Map<String, Double> deyeSettings() {
|
||||
Map<String, Double> settings = new HashMap<>(Map.of("battery_capacity_ah", 280.0, "tou_enabled", 0.0,
|
||||
"max_charge_current_a", 80.0, "max_discharge_current_a", 120.0, "shutdown_soc_pct", 20.0,
|
||||
"low_soc_pct", 35.0, "restart_soc_pct", 50.0, "grid_charge_enabled", 1.0,
|
||||
"grid_charge_current_a", 40.0, "grid_charge_start_soc_pct", 30.0));
|
||||
double[] starts = {100, 500, 900, 1300, 1700, 2100};
|
||||
for (int slot = 1; slot <= 6; slot++) {
|
||||
settings.put("prog" + slot + "_start_hhmm", starts[slot - 1]);
|
||||
settings.put("prog" + slot + "_power_w", 10000.0);
|
||||
settings.put("prog" + slot + "_soc_pct", 80.0);
|
||||
settings.put("prog" + slot + "_grid_charge", 0.0);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
|
||||
.build();
|
||||
@@ -241,7 +352,134 @@ class ApiServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void piSensorMatchesExample() throws Exception {
|
||||
void batteryMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/battery", "battery");
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryWithStaleSettingsMatchesExample() throws Exception {
|
||||
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||
devices.set(devices.size() - 2, new DeviceSnapshot("deye-settings", true, old, null,
|
||||
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), old))));
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/battery", "battery-settings-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryWithoutGatewayCommandsIsNotEditable() throws Exception {
|
||||
batteryDown = true;
|
||||
|
||||
var body = JSON.readTree(get("/api/battery").body());
|
||||
|
||||
assertTrue(body.get("reserve").isNull());
|
||||
assertEquals(false, body.get("editable").asBoolean());
|
||||
assertEquals(99.0, body.get("socPct").asDouble());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
|
||||
var response = post("/api/battery/schedule", example("battery-schedule-request").toString(),
|
||||
"Remote-User", "anna");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
|
||||
new BatteryChange.Slot(2, java.time.LocalTime.of(9, 30), 90, true),
|
||||
new BatteryChange.Slot(3, null, 85, null))) + " by anna"), batterySent);
|
||||
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
|
||||
JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reserveChangeGoesToTheGateway() throws Exception {
|
||||
var response = post("/api/battery/reserve", example("battery-reserve-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(List.of("reserve 60 by phone"), batterySent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryStatusFollowsTheGatewaysVerdict() throws Exception {
|
||||
var expected = Map.of(BatteryResult.Status.REJECTED, 422, BatteryResult.Status.STALE, 409,
|
||||
BatteryResult.Status.TOO_SOON, 429, BatteryResult.Status.FAILED, 502);
|
||||
for (var entry : expected.entrySet()) {
|
||||
batteryVerdict = entry.getKey();
|
||||
|
||||
assertEquals(entry.getValue(), post("/api/battery/reserve", "{\"pct\":60}").statusCode());
|
||||
assertEquals(entry.getValue(), post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
|
||||
}
|
||||
batteryVerdict = BatteryResult.Status.FAILED;
|
||||
assertEquals(example("battery-failed"),
|
||||
JSON.readTree(post("/api/battery/schedule", "{\"enabled\":false}").body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryChangeWithTheGatewayDownIs503() throws Exception {
|
||||
batteryDown = true;
|
||||
|
||||
assertEquals(503, post("/api/battery/reserve", "{\"pct\":60}").statusCode());
|
||||
assertEquals(503, post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badBatteryRequestsAre400AndSendNothing() throws Exception {
|
||||
for (String body : List.of("", "not json", "[]", "{}", "{\"pct\":\"60\"}", "{\"pct\":60.5}", "{\"pct\":null}",
|
||||
"{\"pct\":60,\"x\":1}")) {
|
||||
assertEquals(400, post("/api/battery/reserve", body).statusCode(), body);
|
||||
}
|
||||
for (String body : List.of("", "not json", "[]", "{\"enabled\":\"yes\"}", "{\"slots\":[{\"slot\":1,\"from\":\"9h\"}]}",
|
||||
"{\"slots\":[{\"slot\":1,\"from\":\"25:00\"}]}", "{\"slots\":[{\"slot\":1,\"socPct\":\"9\"}]}",
|
||||
"{\"slots\":[{\"slot\":1,\"soc\":9}]}", "{\"slots\":[{\"slot\":1,\"gridCharge\":1}]}")) {
|
||||
assertEquals(400, post("/api/battery/schedule", body).statusCode(), body);
|
||||
}
|
||||
assertTrue(batterySent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryWritesAreGuardedLikeEveryWrite() throws Exception {
|
||||
for (String path : List.of("/api/battery/reserve", "/api/battery/schedule")) {
|
||||
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode(), path);
|
||||
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode(), path);
|
||||
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode(), path);
|
||||
assertEquals(405, get(path).statusCode(), path);
|
||||
}
|
||||
assertEquals(404, post("/api/battery/other", "{}").statusCode());
|
||||
assertTrue(batterySent.isEmpty());
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -261,11 +499,48 @@ class ApiServerTest {
|
||||
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;
|
||||
|
||||
@@ -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("Ciepła woda – oszczędnie", "Rezerwa baterii", "Ogrzewanie – 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"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView;
|
||||
import home.backend.domain.EnergyDetails;
|
||||
import home.backend.domain.EnergyDetails.Device;
|
||||
import home.backend.domain.EnergyDetails.Group;
|
||||
@@ -23,6 +26,7 @@ import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -171,6 +175,170 @@ 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 batteryScheduleIsEmptyWhenASlotStartIsMissing() throws IOException {
|
||||
Map<String, Double> fields = settings(1);
|
||||
fields.remove("prog3_start_hhmm");
|
||||
|
||||
assertTrue(battery(deyeSettings(fields, READ)).schedule().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryModeFollowsTimeOfUse() throws IOException {
|
||||
BatteryView backup = battery(deyeSettings(settings(0), READ));
|
||||
BatteryView scheduled = battery(deyeSettings(settings(1), READ));
|
||||
|
||||
assertFalse(backup.mode().touEnabled());
|
||||
assertEquals("Rezerwa na awarię: bateria nie rozładowuje się na co dzień", backup.mode().description());
|
||||
assertTrue(scheduled.mode().touEnabled());
|
||||
assertEquals("Harmonogram: bateria pracuje według przedziałów", scheduled.mode().description());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batterySettingsAreStaleAfterThirtyMinutesButLiveValuesStayCurrent() throws IOException {
|
||||
var live = device("deye", "home", "deye", Map.of("battery_soc_pct", 80.0));
|
||||
|
||||
BatteryView fresh = battery(live, deyeSettings(settings(0), NOW.minusSeconds(1800)));
|
||||
BatteryView old = battery(live, deyeSettings(settings(0), NOW.minusSeconds(1801)));
|
||||
|
||||
assertFalse(fresh.settingsStale());
|
||||
assertEquals(6, fresh.schedule().size());
|
||||
assertTrue(old.settingsStale());
|
||||
assertEquals(NOW.minusSeconds(1801), old.settingsTime());
|
||||
assertTrue(old.schedule().isEmpty());
|
||||
assertNull(old.mode());
|
||||
assertNull(old.limits());
|
||||
assertNull(old.capacityKwh());
|
||||
assertEquals(80.0, old.socPct());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeyeShowsNoLiveBatteryValues() throws IOException {
|
||||
var old = new Measurement("home", "deye", Map.of("battery_soc_pct", 80.0), NOW.minusSeconds(601));
|
||||
|
||||
BatteryView view = battery(new DeviceSnapshot("deye", true, old.time(), null, List.of(old)),
|
||||
deyeSettings(settings(0), READ));
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertNull(view.socPct());
|
||||
assertNull(view.powerW());
|
||||
assertFalse(view.settingsStale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryWithoutAnyReadingIsStaleTwice() throws IOException {
|
||||
BatteryView view = battery();
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertTrue(view.settingsStale());
|
||||
assertNull(view.time());
|
||||
assertNull(view.settingsTime());
|
||||
}
|
||||
|
||||
private static PiSensorView piSensor(DeviceSnapshot... devices) throws IOException {
|
||||
return new PiSensorService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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,274 @@
|
||||
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 LocalDate today = TODAY;
|
||||
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 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 aForecastFetchedBeforeLocalMidnightIsNotUsedAfterIt() throws IOException {
|
||||
now = Instant.parse("2026-10-24T21:50:00Z");
|
||||
service.current();
|
||||
|
||||
now = Instant.parse("2026-10-24T22:05:00Z");
|
||||
today = TODAY.plusDays(1);
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertEquals(2, forecastCalls.get());
|
||||
assertEquals(today, view.today().date());
|
||||
assertEquals(today.plusDays(2), view.dayAfter().date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedRefreshServesTheOlderWarningsWithoutAskingAgainFor60Seconds() throws IOException {
|
||||
service.current();
|
||||
warningsDown = true;
|
||||
now = START.plus(Duration.ofMinutes(11));
|
||||
service.current();
|
||||
assertEquals(2, warningCalls.get());
|
||||
|
||||
now = now.plusSeconds(59);
|
||||
assertTrue(service.current().stale());
|
||||
assertEquals(2, warningCalls.get());
|
||||
|
||||
now = now.plusSeconds(1);
|
||||
service.current();
|
||||
assertEquals(3, warningCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnyForecastItFailsFastDuringTheBackOff() {
|
||||
forecastDown = true;
|
||||
assertThrows(IOException.class, service::current);
|
||||
|
||||
now = START.plusSeconds(30);
|
||||
assertThrows(IOException.class, service::current);
|
||||
|
||||
assertEquals(1, forecastCalls.get());
|
||||
}
|
||||
|
||||
@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": "Ciepła woda – oszczędnie", "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": "Ogrzewanie – 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,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,36 @@
|
||||
# ADR-013: Battery settings are changed from the app
|
||||
|
||||
Status: accepted · 2026-10-24
|
||||
|
||||
## Context
|
||||
|
||||
The Deye battery is the house's UPS: Time of Use is off and the battery stays full. My father wants to
|
||||
change its program from our app instead of the Solarman cloud. A wrong table can empty the battery
|
||||
before a power cut, so the write path is narrow and refuses when unsure.
|
||||
|
||||
## Decision
|
||||
|
||||
- **Allowed registers only** (FC16, through the one logger link): 146 bit 0 (Time of Use switch; the days
|
||||
in bits 1–7 stay as read), 148–153 slot starts, 166–171 slot SOC, 172–177 grid charge (0 or 1, never the
|
||||
generator values 2 and 3). Nothing else is written.
|
||||
- **Rules**: SOC from the reserve to 100; starts HHMM, strictly increasing from slot 1 to 6; an enabled
|
||||
schedule needs every slot SOC at or above the reserve. The whole table is checked before any write.
|
||||
- **Reserve**: an app setting in `reserve.json` on the Pi, default 50 %, never below the configured floor
|
||||
(30) nor below the inverter's shutdown SOC + 10. It is only raised above a slot SOC while the schedule is
|
||||
off; the user lifts the slots first. Lowering is always fine.
|
||||
- **Refuse on unknown state**: no reading of the settings in the last 30 minutes, or the registers read just
|
||||
before writing differ from that reading (the Solarman app changed them): 409, nothing written.
|
||||
- **Verify, never retry**: neighbouring registers go in one FC16, the switch last; all are read back; any
|
||||
mismatch is FAILED with what was read. The user decides what to do next.
|
||||
- **No restore, no lease**: these are persistent settings as in the Solarman app; the reserve is what
|
||||
protects the UPS role (ADR-011 stays for the heat pump).
|
||||
- **Audit and limits**: S3 into `electricity.deye_settings_audit`, S4 per register, token and throttle as
|
||||
`/commands`. Off unless `deyeCommands.enabled`, which needs the Deye.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The gateway writes to the inverter for the first time; registers beyond the list stay read-only.
|
||||
- A change made in the Solarman app while the app shows the old table is refused, not overwritten.
|
||||
- The reserve is enforced only through the slot SOC rule; the inverter does not know it.
|
||||
- The shutdown SOC comes from the last settings reading; a change of it applies from the next poll.
|
||||
- The Solarman cloud stays outside our system; the logger link has one writer at a time.
|
||||
@@ -40,6 +40,7 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|
||||
| M2 Understand the controller | Timing (safe poll rate, response times), which pumps run in which state, open questions in `docs/frisko/` | `docs/frisko/` reviewed; F3 limits agreed |
|
||||
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; Traefik route, Krosno only and no login for now (ADR-009). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); open: `REGISTRY_TOKEN`, first release, the stack in homelab-compose (Arcane) | My father sees the heat pump and the energy on his phone |
|
||||
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011), away held until a chosen date (ADR-012); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
|
||||
| M4b Battery settings | F8 (ADR-013): Time of Use table and backup reserve from the Bateria view, FC16 with read-back, refused on stale or changed state; on branch/master and off until `deyeCommands.enabled` (gateway/README.md: Enabling battery commands) | Father changes the battery program from the app; first live write watched |
|
||||
| M5 Alarms | F4; decide the channel first (e.g. ntfy, Telegram) | Simulated alarm reaches the phone |
|
||||
| M6 Native app? | Decide: keep the web UI, WebView wrapper or React Native | Decision recorded |
|
||||
|
||||
|
||||
+9
-4
@@ -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.
|
||||
@@ -76,7 +76,8 @@ Functional:
|
||||
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
|
||||
heater, alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
|
||||
pressure) of the living room.
|
||||
- F2 History for the last 7 days.
|
||||
- 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.
|
||||
@@ -91,13 +92,17 @@ Functional:
|
||||
- F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery).
|
||||
- F5 Health of backend, device links and tunnel.
|
||||
- F6 Later, low priority: show the weekly program (reading needs selector writes).
|
||||
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals; a details panel per device (Deye, Fronius, house meter).
|
||||
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals; a details panel per device (Deye, Fronius, house meter); a battery view with SOC, power, the Deye's program schedule and limits.
|
||||
|
||||
- F8 Battery settings (ADR-013, off until enabled): change the Deye's Time of Use switch and the six slots (start, SOC, grid charge) from the Bateria view, and the app's backup reserve (default 50 %, floor 30 %) that every enabled slot SOC must hold. Refused when the settings reading is older than 30 min or changed meanwhile; read back, no restore.
|
||||
|
||||
- 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 opens with the alarm and one collapsible line per distinct IMGW warning, then four key figures (CWU, home, outdoor, power), the schematic with the device rows, the "Pogoda" card (3 days, PV estimate, from `api/weather`) and "Szczegóły temperatur"; 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" }
|
||||
]
|
||||
}
|
||||
+25
-17
@@ -1,15 +1,18 @@
|
||||
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, isStale, Loading } from "./ui";
|
||||
import { useWeather, WeatherIcon } from "./Weather";
|
||||
|
||||
// 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 +20,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";
|
||||
};
|
||||
|
||||
@@ -42,7 +45,7 @@ function ThemeSwitch({ className }: { className: string }) {
|
||||
<div role="group" aria-label="Motyw" className={`flex gap-1 rounded-xl p-1 text-sm ${className}`}>
|
||||
{Object.entries(THEMES).map(([id, label]) => (
|
||||
<button key={id} aria-pressed={id === theme} onClick={() => setTheme(id as Theme)}
|
||||
className="flex-1 rounded-lg px-2.5 py-1.5 font-semibold opacity-70 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
|
||||
className="min-h-11 flex-1 rounded-lg px-2.5 font-semibold opacity-80 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
@@ -54,10 +57,12 @@ function ThemeSwitch({ className }: { className: string }) {
|
||||
function SideFooter() {
|
||||
const { data, offline } = usePoll<HeatPump>("api/heat-pump");
|
||||
const now = useClock();
|
||||
const old = !offline && data?.time != null && isStale(data.time, data.stale);
|
||||
const weather = useWeather();
|
||||
return (
|
||||
<div className="mt-auto space-y-4 px-2">
|
||||
<div className="mt-auto space-y-3 border-t border-white/10 px-2 pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon name="sun" className="size-9 text-amber-400" />
|
||||
{weather && <WeatherIcon code={weather.today.weatherCode} className="size-9" />}
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
|
||||
<p className="text-sm text-white/60">Szczepańcowa</p>
|
||||
@@ -66,9 +71,9 @@ function SideFooter() {
|
||||
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
|
||||
<ThemeSwitch className="bg-white/10" />
|
||||
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<Dot tone={offline ? "bad" : "ok"} live={!offline} />
|
||||
<Dot tone={offline ? "bad" : old ? "warn" : "ok"} live={!offline && !old} />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold">{offline ? "Brak połączenia" : "System online"}</p>
|
||||
<p className="font-semibold">{offline ? "Brak połączenia" : old ? "Dane nieaktualne" : "System online"}</p>
|
||||
<p className="text-white/60">{data?.time ? `Ostatni odczyt: ${hour(new Date(data.time))}` : "Czekam na odczyt"}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,14 +84,17 @@ function SideFooter() {
|
||||
export function App() {
|
||||
const [screen, setScreen] = useState<ScreenId>(fromHash);
|
||||
useEffect(() => {
|
||||
const onHash = () => setScreen(fromHash());
|
||||
// A link to another screen, such as "Energia ›", arrives here too.
|
||||
const onHash = () => {
|
||||
setScreen(fromHash());
|
||||
scrollTo(0, 0);
|
||||
};
|
||||
addEventListener("hashchange", onHash);
|
||||
return () => removeEventListener("hashchange", onHash);
|
||||
}, []);
|
||||
const show = (id: ScreenId) => {
|
||||
location.hash = id;
|
||||
setScreen(id);
|
||||
scrollTo(0, 0);
|
||||
};
|
||||
const View = SCREENS[screen].view;
|
||||
|
||||
@@ -97,8 +105,8 @@ export function App() {
|
||||
<div className="mb-8 hidden items-center gap-3 px-2 lg:flex">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-6" /></span>
|
||||
<div>
|
||||
<p className="text-lg font-bold leading-tight">Dom Szczepańcowa</p>
|
||||
<p className="text-xs text-white/60">Pompa ciepła i energia</p>
|
||||
<p className="text-base font-bold leading-tight">Dom Szczepańcowa</p>
|
||||
<p className="text-xs text-white/70">Pompa ciepła i energia</p>
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(SCREENS).map(([id, s]) => (
|
||||
@@ -111,7 +119,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 / 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("Harmonogram wyłączony – bateria tylko na awarię.")).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: —" }).closest(".opacity-50")).not.toBeNull();
|
||||
expect(screen.getByText("Dziś naładowano").parentElement).toHaveTextContent("—");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import { flowing } from "./flow";
|
||||
import { amps, DASH, dateTime, 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, isStale, 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">{number(stored, 1)} / {kwh(capacity)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Now({ b, stale }: { b: BatteryData; stale: boolean }) {
|
||||
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 ${stale ? "opacity-50" : ""}`}>
|
||||
<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-brand" : ""}`}>
|
||||
{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 threats = useWeather()?.warnings.filter(threatensPower) ?? [];
|
||||
if (!b) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
return (
|
||||
<>
|
||||
<Freshness offline={offline} time={b.time} stale={b.stale} compact />
|
||||
{threats.length > 0 && (
|
||||
<Banner tone="warn" compact>
|
||||
<p>Zapowiadany silny wiatr lub burza – zostaw baterię naładowaną (rezerwa na awarię).</p>
|
||||
<details className="mt-1 font-normal">
|
||||
<summary className="cursor-pointer py-1 font-semibold">Szczegóły ostrzeżenia</summary>
|
||||
<ul>{threats.map((w) => <li key={`${w.event}${w.level}${w.from}`}>IMGW, stopień {w.level}: {w.event}, {dateTime(w.from)} – {dateTime(w.to)}</li>)}</ul>
|
||||
</details>
|
||||
</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} stale={isStale(b.time, b.stale)} /></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,277 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } 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("lists slots that share a start without a duplicate key", async () => {
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const schedule = example<BatteryData>("battery").schedule.map((slot) => ({ ...slot, from: "00:00" }));
|
||||
await show({ schedule });
|
||||
|
||||
expect(screen.getAllByText(/^00:00–/)).toHaveLength(6);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
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,301 @@
|
||||
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, 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-base 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={edit}>
|
||||
{off && <p className="mb-3 font-semibold">Harmonogram wyłączony – bateria tylko na awarię.</p>}
|
||||
<table className={`w-full text-base ${off ? "opacity-50" : ""}`}>
|
||||
<caption className="sr-only">Przedziały pracy baterii{off ? " (wyłączone)" : ""}</caption>
|
||||
<thead>
|
||||
<tr className="text-left text-soft">
|
||||
<th scope="col" className="pb-2 font-semibold">Godziny</th>
|
||||
<th scope="col" className="pb-2 font-semibold">Poziom</th>
|
||||
<th scope="col" className="pb-2 text-right font-semibold">Z sieci</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{b.schedule.map((slot, i) => (
|
||||
<tr key={i + 1} 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("ustawiono 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from "react";
|
||||
import { dateTime, degrees, number, percent } from "./format";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Audit, AuditEntry } from "./types";
|
||||
import { Card, Pill } from "./ui";
|
||||
|
||||
const URL = "api/audit?range=7d";
|
||||
const PAGE = 20;
|
||||
// A failure may have reached the pump, so it is red; the others changed nothing, so amber.
|
||||
const STATUS: Record<string, { text: string; tone: "bad" | "warn" }> = {
|
||||
FAILED: { text: "nieudana", tone: "bad" }, REJECTED: { text: "odrzucona", tone: "warn" },
|
||||
CONFLICT: { text: "konflikt", tone: "warn" }, INVALID: { text: "nieważna", tone: "warn" },
|
||||
};
|
||||
|
||||
// 1830 is a clock time, 0/1 is off/on.
|
||||
const SHOWN: Record<string, (value: number) => string> = {
|
||||
"°C": degrees,
|
||||
"%": percent,
|
||||
HHMM: (value) => `${String(Math.floor(value / 100)).padStart(2, "0")}:${String(value % 100).padStart(2, "0")}`,
|
||||
"0/1": (value) => (value ? "tak" : "nie"),
|
||||
};
|
||||
const shown = (value: number | null, unit: string) => (value == null ? "—" : (SHOWN[unit] ?? number)(value));
|
||||
|
||||
function Row({ entry }: { entry: AuditEntry }) {
|
||||
const status = entry.status === "APPLIED" ? null : (STATUS[entry.status] ?? { text: entry.status, tone: "warn" as const });
|
||||
return (
|
||||
<li className="border-t border-edge py-3 first:border-t-0 first:pt-0">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="font-semibold">{entry.what}</span>
|
||||
<span className="shrink-0 text-sm text-soft">{dateTime(entry.time)}</span>
|
||||
</div>
|
||||
<p className="my-1">
|
||||
{entry.oldValue == null ? <>ustawiono <b>{shown(entry.newValue, entry.unit)}</b></>
|
||||
: <>{shown(entry.oldValue, entry.unit)} → <b>{shown(entry.newValue, entry.unit)}</b></>}
|
||||
</p>
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-soft">
|
||||
{entry.who}
|
||||
{status && <Pill tone={status.tone}>{status.text}</Pill>}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangeHistory() {
|
||||
const { data, offline } = usePoll<Audit>(URL, 60000);
|
||||
const [all, setAll] = useState(false);
|
||||
const entries = data?.entries ?? [];
|
||||
return (
|
||||
<Card title="Historia zmian" aside={<span className="text-sm text-soft">ostatnie 7 dni</span>} className="mt-4">
|
||||
{!data && <p className="my-1 text-soft">{offline ? "Historia chwilowo niedostępna." : "Wczytuję…"}</p>}
|
||||
{data && entries.length === 0 && <p className="my-1 text-soft">Brak zmian w ostatnich 7 dniach</p>}
|
||||
<ul className="m-0 list-none p-0">
|
||||
{(all ? entries : entries.slice(0, PAGE)).map((entry) => (
|
||||
<Row key={`${entry.source}${entry.time}${entry.what}`} entry={entry} />
|
||||
))}
|
||||
</ul>
|
||||
{!all && entries.length > PAGE && (
|
||||
<button type="button" onClick={() => setAll(true)} className="mt-3 min-h-12 w-full rounded-xl border-2 border-edge font-semibold">
|
||||
Pokaż więcej ({entries.length - PAGE})
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { celsius, degrees, number } from "./format";
|
||||
import type { HeatPump, PiSensor } from "./types";
|
||||
import { Card, Row } from "./ui";
|
||||
|
||||
// The Raspberry Pi with the BME280 stands in the living room.
|
||||
const NO_READING = "brak odczytu";
|
||||
const reading = (value: number | null | undefined, digits: number, unit: string) =>
|
||||
value == null ? NO_READING : `${number(value, digits)} ${unit}`;
|
||||
|
||||
function Group({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="mt-3 first:mt-0 [&>div]:py-1.5">
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-soft">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const difference = (a: number | null, b: number | null) => (a != null && b != null ? celsius(a - b) : "—");
|
||||
|
||||
/** Every temperature and setpoint the overview does not already show in its four key figures. */
|
||||
export function TemperatureDetails({ hp, pi }: { hp: HeatPump; pi: PiSensor | undefined }) {
|
||||
const { temperatures: t, targets, setpoints: s } = hp;
|
||||
return (
|
||||
<Card title="Szczegóły temperatur">
|
||||
<Group title="Dom">
|
||||
<Row label="Salon" value={reading(pi?.temperature, 1, "°C")} />
|
||||
<Row label="Wilgotność w salonie" value={reading(pi?.humidity, 1, "%")} />
|
||||
<Row label="Ciśnienie" value={reading(pi?.pressure, 0, "hPa")} />
|
||||
<Row label="Pokój według pompy" value={celsius(t.indoor)} />
|
||||
<Row label="Pokój, cel teraz" value={celsius(targets.room)} />
|
||||
<Row label="Na zewnątrz, średnia" value={celsius(t.outdoorAvg)} />
|
||||
</Group>
|
||||
<Group title="Woda i ogrzewanie">
|
||||
<Row label="Bufor CO" value={celsius(t.coBuffer)} />
|
||||
<Row label="Bufor CO, cel" value={celsius(targets.co)} />
|
||||
<Row label="Wyjście pompy" value={celsius(t.pumpOutlet)} />
|
||||
</Group>
|
||||
<Group title="Dolne źródło">
|
||||
<Row label="Do pompy" value={celsius(t.groundIn)} />
|
||||
<Row label="Z pompy" value={celsius(t.groundOut)} />
|
||||
<Row label="Różnica" value={difference(t.groundIn, t.groundOut)} />
|
||||
</Group>
|
||||
<Group title="Nastawy">
|
||||
<Row label="Ciepła woda – oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
|
||||
<Row label="Ogrzewanie – oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { quantity, when } from "./format";
|
||||
import type { EnergyDetailsDevice } from "./types";
|
||||
import { Row } from "./ui";
|
||||
import { Dot, isStale, Row } from "./ui";
|
||||
|
||||
/** The tappable row that says in words and with a chevron that a device has more to show. */
|
||||
export function DetailsButton({ device, onOpen }: { device: EnergyDetailsDevice; onOpen: () => void }) {
|
||||
/** The tappable row: status, the main current value and the read time; the chevron says there is more. */
|
||||
export function DetailsButton({ device, summary, onOpen }: { device: EnergyDetailsDevice; summary: string; onOpen: () => void }) {
|
||||
const stale = device.stale || isStale(device.time);
|
||||
const read = stale ? (device.time ? `brak odczytu od ${when(device.time)}` : "brak odczytu") : device.time && `odczyt ${when(device.time)}`;
|
||||
return (
|
||||
<button type="button" onClick={onOpen}
|
||||
className="flex min-h-14 w-full items-center justify-between gap-3 rounded-xl border border-edge px-4 py-2 text-left hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
|
||||
className="flex min-h-16 w-full items-center justify-between gap-3 rounded-xl border border-edge px-4 py-2 text-left hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
|
||||
<span>
|
||||
<span className="block font-semibold">{device.name}</span>
|
||||
{device.stale && <span className="block text-xs text-soft">brak odczytu</span>}
|
||||
<span className="flex items-center gap-2 font-semibold">
|
||||
<Dot tone={stale ? "warn" : "ok"} />{device.name}
|
||||
</span>
|
||||
{!stale && summary && <span className="block text-lg font-bold tabular-nums">{summary}</span>}
|
||||
{read && <span className="block text-sm text-soft">{read}</span>}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 font-semibold text-brand">
|
||||
Szczegóły <span aria-hidden="true" className="text-xl leading-none">›</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { Energy as EnergyData, EnergyDetails } from "./types";
|
||||
|
||||
@@ -8,12 +8,84 @@ import type { Energy as EnergyData, EnergyDetails } from "./types";
|
||||
const energy = async () => (await import("./Energy")).Energy;
|
||||
|
||||
function serveEnergy(details: EnergyDetails = example("energy-details")) {
|
||||
serve({ "api/energy/details": details, "api/energy": example("energy") });
|
||||
serve({ "api/energy/details": details, "api/energy": example("energy"), "api/battery": example("battery") });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
location.hash = "";
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
// The fixtures are from a fixed evening; this makes them "just read".
|
||||
const justAfterFixture = () => vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-10-23T22:28:00Z") });
|
||||
|
||||
describe("Energy views", () => {
|
||||
const tab = (name: string) => screen.getByRole("tab", { name });
|
||||
|
||||
it("starts on Teraz with three tabs, each at least 44 px high", async () => {
|
||||
serveEnergy();
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Teraz", "Bateria", "Falowniki"]);
|
||||
expect(tab("Teraz")).toHaveAttribute("aria-selected", "true");
|
||||
expect(tab("Teraz")).toHaveClass("min-h-11");
|
||||
expect(await screen.findByText("Energia teraz")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Urządzenia")).toBeNull();
|
||||
});
|
||||
|
||||
it("switches views and keeps each in the URL hash", async () => {
|
||||
serveEnergy();
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
await userEvent.click(tab("Bateria"));
|
||||
expect(location.hash).toBe("#energy/battery");
|
||||
expect(tab("Bateria")).toHaveAttribute("aria-selected", "true");
|
||||
expect(await screen.findByText("Harmonogram")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(tab("Falowniki"));
|
||||
expect(location.hash).toBe("#energy/inverters");
|
||||
expect(await screen.findByRole("button", { name: /Falownik Deye.*Szczegóły/ })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(tab("Teraz"));
|
||||
expect(location.hash).toBe("#energy");
|
||||
});
|
||||
|
||||
it("opens the view named by the hash and Back returns to the previous one", async () => {
|
||||
serveEnergy();
|
||||
location.hash = "energy/inverters";
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
expect(tab("Falowniki")).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await userEvent.click(tab("Bateria"));
|
||||
expect(tab("Bateria")).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
history.back();
|
||||
await waitFor(() => expect(tab("Falowniki")).toHaveAttribute("aria-selected", "true"));
|
||||
});
|
||||
|
||||
it("opens Bateria when the battery in the flow diagram is tapped, and Back returns to Teraz", async () => {
|
||||
serveEnergy();
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Bateria.*szczegóły/ }));
|
||||
expect(tab("Bateria")).toHaveAttribute("aria-selected", "true");
|
||||
expect(location.hash).toBe("#energy/battery");
|
||||
|
||||
history.back();
|
||||
await waitFor(() => expect(tab("Teraz")).toHaveAttribute("aria-selected", "true"));
|
||||
expect(await screen.findByText("Energia teraz")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Energy details", () => {
|
||||
it("offers every device with a visible way to open it", async () => {
|
||||
serveEnergy();
|
||||
location.hash = "energy/inverters";
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
@@ -23,8 +95,37 @@ describe("Energy details", () => {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the main value and the read time on each row, and says when a device is silent", async () => {
|
||||
justAfterFixture();
|
||||
const details = example<EnergyDetails>("energy-details");
|
||||
serveEnergy({ devices: details.devices.map((d) => (d.id === "fronius" ? { ...d, stale: true, groups: [] } : d)) });
|
||||
location.hash = "energy/inverters";
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
expect(await screen.findByRole("button", { name: /Falownik Deye/ })).toHaveTextContent("Bateria 99 % · PV 0 W");
|
||||
expect(screen.getByRole("button", { name: /Licznik domu/ })).toHaveTextContent("645 W pobór");
|
||||
expect(screen.getByRole("button", { name: /Falownik Fronius/ })).toHaveTextContent("brak odczytu od");
|
||||
expect(screen.getByRole("button", { name: /Falownik Fronius/ })).not.toHaveTextContent("PV");
|
||||
});
|
||||
|
||||
it("dims the values of a source that has gone quiet", async () => {
|
||||
justAfterFixture();
|
||||
const e = example<EnergyData>("energy");
|
||||
serve({ "api/energy/details": example("energy-details"), "api/battery": example("battery"),
|
||||
"api/energy": { ...e, sources: e.sources.map((s) => (s.name === "deye" ? { ...s, stale: true } : s)) } });
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
const stats = (await screen.findByText("Zużycie domu")).closest("section")!;
|
||||
expect(screen.getByText("Produkcja PV", { selector: "p" }).closest("section")).toHaveClass("opacity-60");
|
||||
expect(screen.getAllByText("Bateria", { selector: "p" })[0].closest("section")).toHaveClass("opacity-60");
|
||||
expect(stats).not.toHaveClass("opacity-60");
|
||||
});
|
||||
|
||||
it("opens the groups of a device and closes by button, backdrop and Escape", async () => {
|
||||
serveEnergy();
|
||||
location.hash = "energy/inverters";
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
@@ -52,6 +153,7 @@ describe("Energy details", () => {
|
||||
|
||||
it("stays open when the panel itself is tapped", async () => {
|
||||
serveEnergy();
|
||||
location.hash = "energy/inverters";
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
@@ -64,6 +166,7 @@ describe("Energy details", () => {
|
||||
it("says there is no current data for a device without a fresh reading", async () => {
|
||||
const details = example<EnergyDetails>("energy-details");
|
||||
serveEnergy({ devices: details.devices.map((d) => (d.id === "fronius" ? { ...d, stale: true, groups: [] } : d)) });
|
||||
location.hash = "energy/inverters";
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
|
||||
+107
-46
@@ -1,13 +1,15 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useState, type KeyboardEvent } from "react";
|
||||
import { Cell, Pie, PieChart } from "recharts";
|
||||
import { Battery } from "./Battery";
|
||||
import { flowing } from "./flow";
|
||||
import { kwh, percent, power, when } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Energy as EnergyData, EnergyDetails } from "./types";
|
||||
import type { Energy as EnergyData, EnergyDetails, EnergyDetailsDevice } from "./types";
|
||||
import { DetailsButton, DeviceDetails } from "./DeviceDetails";
|
||||
import { Banner, Card, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
import { SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
|
||||
import { Banner, Card, Freshness, isStale, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
|
||||
const FLOWING_W = 10;
|
||||
const INVERTERS = { deye: "Falownik Deye", fronius: "Falownik Fronius" } as const;
|
||||
|
||||
/** Inverters without a fresh reading, in plain words. A source the backend does not list is missing too. */
|
||||
@@ -15,11 +17,10 @@ export function silentInverters(e: EnergyData) {
|
||||
return Object.entries(INVERTERS).flatMap(([id, label]) => {
|
||||
const source = e.sources?.find((s) => s.name === id);
|
||||
if (source && !source.stale && source.time) return [];
|
||||
return [{ label, since: source?.time ? `brak odczytu od ${when(source.time)}` : "brak odczytu" }];
|
||||
return [{ id, label, since: source?.time ? `brak odczytu od ${when(source.time)}` : "brak odczytu" }];
|
||||
});
|
||||
}
|
||||
|
||||
const flowing = (watts: number | null) => watts != null && Math.abs(watts) >= FLOWING_W;
|
||||
const magnitude = (watts: number | null) => (watts == null ? null : Math.abs(watts));
|
||||
|
||||
// The SDM630 and Deye sign: + the house takes from the grid, − it gives back.
|
||||
@@ -37,17 +38,17 @@ function batteryText(watts: number | null) {
|
||||
|
||||
// Nodes sit around the house; dots run along a line from the source to the sink of each flow.
|
||||
const NODES: Record<"pv" | "grid" | "house" | "battery", { x: number; y: number; label: string; icon: IconName; colour: string }> = {
|
||||
pv: { x: 90, y: 70, label: "Słońce (PV)", icon: "sun", colour: "#f59e0b" },
|
||||
grid: { x: 510, y: 70, label: "Sieć", icon: "plug", colour: "#3b82f6" },
|
||||
house: { x: 300, y: 170, label: "Dom", icon: "home", colour: "#10b981" },
|
||||
battery: { x: 300, y: 300, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
|
||||
pv: { x: 94, y: 56, label: "Słońce (PV)", icon: "sun", colour: "#f59e0b" },
|
||||
grid: { x: 286, y: 56, label: "Sieć", icon: "plug", colour: "#3b82f6" },
|
||||
house: { x: 190, y: 196, label: "Dom", icon: "home", colour: "#10b981" },
|
||||
battery: { x: 190, y: 336, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
|
||||
};
|
||||
|
||||
// A triangle halfway along the line, pointing at the sink: the direction is readable without the moving dots.
|
||||
function Arrowhead({ from, to }: { from: { x: number; y: number; colour: string }; to: { x: number; y: number } }) {
|
||||
const angle = (Math.atan2(to.y - from.y, to.x - from.x) * 180) / Math.PI;
|
||||
const x = (from.x + to.x) / 2, y = (from.y + to.y) / 2;
|
||||
return <polygon data-arrow points="-11,-10 11,0 -11,10" fill={from.colour} transform={`translate(${x} ${y}) rotate(${angle})`} />;
|
||||
return <polygon data-arrow points="-13,-12 13,0 -13,12" fill={from.colour} transform={`translate(${x} ${y}) rotate(${angle})`} />;
|
||||
}
|
||||
|
||||
function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof NODES; watts: number | null }) {
|
||||
@@ -58,23 +59,36 @@ function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof
|
||||
// Faster dots for more power: 2.5 s per run at 10 W, down to 0.6 s at 5 kW and more.
|
||||
const speed = on ? Math.max(0.6, 2.5 - (Math.min(Math.abs(watts!), 5000) / 5000) * 1.9) : 0;
|
||||
return (
|
||||
<g data-flow={`${from}-${to}`} data-on={on}>
|
||||
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 5 : 3} strokeLinecap="round" opacity={on ? 0.35 : 0.6} />
|
||||
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={5} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
|
||||
<g data-flow={`${from}-${to}`} data-on={on} aria-hidden="true">
|
||||
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 7 : 4} strokeLinecap="round" opacity={on ? 0.45 : 1} />
|
||||
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={7} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
|
||||
{on && <Arrowhead from={s} to={e} />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({ id, value, note }: { id: keyof typeof NODES; value: string; note?: string }) {
|
||||
// With onOpen the node is a button: the chevron says it leads somewhere.
|
||||
function Node({ id, value, note, dim, onOpen }: { id: keyof typeof NODES; value: string; note?: string; dim: boolean; onOpen?: () => void }) {
|
||||
const { x, y, label, icon, colour } = NODES[id];
|
||||
const button = onOpen && {
|
||||
role: "button", tabIndex: 0, "aria-label": `${label}: pokaż szczegóły`, onClick: onOpen, className: "cursor-pointer outline-offset-2 focus-visible:outline-2 focus-visible:outline-brand",
|
||||
onKeyDown: (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
},
|
||||
};
|
||||
return (
|
||||
<g>
|
||||
<rect x={x - 78} y={y - 40} width={156} height={80} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={1.5} />
|
||||
<g transform={`translate(${x - 64} ${y - 26})`} color={colour}><Icon name={icon} size={28} /></g>
|
||||
<text x={x - 28} y={y - 10} fontSize={14} className="scene-soft">{label}</text>
|
||||
<text x={x - 28} y={y + 16} fontSize={22} fontWeight={700} className="scene-text">{value}</text>
|
||||
{note && <text x={x - 28} y={y + 33} fontSize={12} className="scene-soft">{note}</text>}
|
||||
<g aria-hidden={onOpen ? undefined : true} {...button}>
|
||||
<rect x={x - 86} y={y - 44} width={172} height={88} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={2} />
|
||||
<g transform={`translate(${x - 76} ${y - 38})`} color={colour}><Icon name={icon} size={28} /></g>
|
||||
<g opacity={dim ? 0.45 : 1}>
|
||||
<text x={x - 40} y={y - 10} fontSize={16} className="scene-soft">{label}</text>
|
||||
<text x={x - 40} y={y + 18} fontSize={26} fontWeight={700} className="scene-text">{value}</text>
|
||||
{note && <text x={x - 40} y={y + 37} fontSize={14} className="scene-soft">{note}</text>}
|
||||
</g>
|
||||
{onOpen && <text x={x + 72} y={y + 10} fontSize={32} textAnchor="middle" className="scene-soft" aria-hidden="true">›</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -91,25 +105,33 @@ export function flowSummary(e: EnergyData) {
|
||||
];
|
||||
}
|
||||
|
||||
function FlowPicture({ e }: { e: EnergyData }) {
|
||||
// A source without a fresh reading keeps its last value on the screen, so the value is dimmed.
|
||||
function quietNodes(e: EnergyData): string[] {
|
||||
if (isStale(e.time)) return Object.keys(NODES);
|
||||
return silentInverters(e).flatMap(({ id }) => (id === "deye" ? ["pv", "battery"] : ["pv"]));
|
||||
}
|
||||
|
||||
function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void }) {
|
||||
const quiet = quietNodes(e);
|
||||
const dim = (id: keyof typeof NODES) => quiet.includes(id);
|
||||
return (
|
||||
<>
|
||||
<ul className="sr-only">{flowSummary(e).map((t) => <li key={t}>{t}</li>)}</ul>
|
||||
<svg viewBox="0 0 600 350" className="h-auto w-full" aria-hidden="true">
|
||||
<svg viewBox="0 0 380 400" className="mx-auto h-auto w-full max-w-md">
|
||||
<Flow from="pv" to="house" watts={e.pvPowerW} />
|
||||
<Flow from="grid" to="house" watts={e.gridPowerW} />
|
||||
<Flow from="battery" to="house" watts={e.batteryPowerW} />
|
||||
<Node id="pv" value={power(e.pvPowerW)} />
|
||||
<Node id="grid" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Node id="house" value={power(e.loadPowerW)} note="zużycie" />
|
||||
<Node id="battery" value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
|
||||
<Node id="pv" dim={dim("pv")} value={power(e.pvPowerW)} />
|
||||
<Node id="grid" dim={dim("grid")} value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Node id="house" dim={dim("house")} value={power(e.loadPowerW)} note="zużycie" />
|
||||
<Node id="battery" dim={dim("battery")} onOpen={onBattery} value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Today's use split by where it came from; the battery is charged from both, so "own" is what the grid did not give.
|
||||
export function EnergyToday({ e }: { e: EnergyData | undefined }) {
|
||||
export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; link?: boolean }) {
|
||||
const today = e?.today;
|
||||
const load = today?.loadKwh ?? null;
|
||||
const bought = today?.gridBoughtKwh ?? null;
|
||||
@@ -119,19 +141,25 @@ export function EnergyToday({ e }: { e: EnergyData | undefined }) {
|
||||
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
|
||||
];
|
||||
const partial = e ? silentInverters(e).length > 0 : false;
|
||||
const aside = (
|
||||
<span className="flex items-center gap-3">
|
||||
{partial && <Pill tone="warn">Tylko część danych</Pill>}
|
||||
{link && <a href="#energy" className="font-semibold text-brand">Energia ›</a>}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Card title="Energia – dziś" aside={partial ? <Pill tone="warn">Tylko część danych</Pill> : undefined}>
|
||||
<Card title="Energia – dziś" aside={aside}>
|
||||
{!e ? <Loading /> : (
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<div className="relative size-36 shrink-0">
|
||||
<div className={`relative size-36 shrink-0 ${isStale(e?.time) ? "opacity-50" : ""}`}>
|
||||
<PieChart width={144} height={144}>
|
||||
<Pie data={parts} dataKey="value" innerRadius={50} outerRadius={68} startAngle={90} endAngle={-270}
|
||||
<Pie data={parts} dataKey="value" innerRadius={54} outerRadius={68} startAngle={90} endAngle={-270}
|
||||
stroke="none" isAnimationActive={false}>
|
||||
{parts.map((p) => <Cell key={p.name} fill={p.colour} />)}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
<div className="absolute inset-0 grid place-items-center text-center">
|
||||
<div><p className="text-xl font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
|
||||
<div><p className="text-lg font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-44 flex-1 text-sm">
|
||||
@@ -148,9 +176,9 @@ export function EnergyToday({ e }: { e: EnergyData | undefined }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value, note }: { icon: IconName; label: string; value: string; note?: string }) {
|
||||
function Stat({ icon, label, value, note, dim }: { icon: IconName; label: string; value: string; note?: string; dim: boolean }) {
|
||||
return (
|
||||
<Card>
|
||||
<Card className={dim ? "opacity-60" : ""}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-brand-soft text-brand"><Icon name={icon} /></span>
|
||||
<div>
|
||||
@@ -163,16 +191,34 @@ function Stat({ icon, label, value, note }: { icon: IconName; label: string; val
|
||||
);
|
||||
}
|
||||
|
||||
const reading = (d: EnergyDetailsDevice, group: string, label?: string) =>
|
||||
d.groups.find((g) => g.title === group)?.items.filter((item) => !label || item.label === label) ?? [];
|
||||
|
||||
// A value wraps between its parts, never inside one.
|
||||
const unbreakable = (text: string) => text.replaceAll(" ", "\u00a0");
|
||||
|
||||
/** The main current values of a device, from the details and, for the house meter, from the flow. */
|
||||
export function deviceSummary(d: EnergyDetailsDevice, e: EnergyData | undefined): string {
|
||||
if (d.id === "deye") {
|
||||
const pv = reading(d, "Panele PV").reduce((sum, item) => sum + item.value, 0);
|
||||
return [`Bateria ${percent(reading(d, "Bateria", "Naładowanie")[0]?.value)}`, `PV ${power(pv)}`].map(unbreakable).join(" · ");
|
||||
}
|
||||
if (d.id === "fronius") return `PV ${power(reading(d, "Teraz", "Moc PV")[0]?.value)}`;
|
||||
if (d.id === "house-meter" && e) return `${power(magnitude(e.gridPowerW))} ${gridText(e.gridPowerW)}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function Devices() {
|
||||
const { data } = usePoll<EnergyDetails>("api/energy/details");
|
||||
const { data: energy } = usePoll<EnergyData>("api/energy");
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const close = useCallback(() => setOpenId(null), []);
|
||||
const open = data?.devices.find((d) => d.id === openId);
|
||||
return (
|
||||
<Card title="Urządzenia" className="lg:col-span-3">
|
||||
<Card title="Urządzenia">
|
||||
{!data ? <Loading /> : (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{data.devices.map((d) => <DetailsButton key={d.id} device={d} onOpen={() => setOpenId(d.id)} />)}
|
||||
{data.devices.map((d) => <DetailsButton key={d.id} device={d} summary={deviceSummary(d, energy)} onOpen={() => setOpenId(d.id)} />)}
|
||||
</div>
|
||||
)}
|
||||
{open && <DeviceDetails device={open} onClose={close} />}
|
||||
@@ -180,27 +226,42 @@ function Devices() {
|
||||
);
|
||||
}
|
||||
|
||||
export function Energy() {
|
||||
function Now({ onBattery }: { onBattery: () => void }) {
|
||||
const { data: e, offline } = usePoll<EnergyData>("api/energy");
|
||||
const title = <PageTitle title="Energia" subtitle="Falowniki Deye i Fronius, licznik domu" />;
|
||||
if (!e) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
if (!e) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
const missing = silentInverters(e);
|
||||
const quiet = quietNodes(e);
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
<Freshness offline={offline} time={e.time} />
|
||||
{missing.length > 0 && <Banner tone="warn">{missing.map((m) => <div key={m.label}>{m.label}: {m.since}</div>)}</Banner>}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card title="Energia teraz" className="lg:col-span-2"><FlowPicture e={e} /></Card>
|
||||
<Card title="Energia teraz" className="lg:col-span-2"><FlowPicture e={e} onBattery={onBattery} /></Card>
|
||||
<EnergyToday e={e} />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3 lg:grid-cols-4">
|
||||
<Stat icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
|
||||
<Stat icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
|
||||
<Stat icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Stat icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
|
||||
<Stat dim={quiet.includes("pv")} icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
|
||||
<Stat dim={quiet.includes("house")} icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
|
||||
<Stat dim={quiet.includes("grid")} icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Stat dim={quiet.includes("battery")} icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
|
||||
</div>
|
||||
<Devices />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const VIEWS = { now: "Teraz", battery: "Bateria", inverters: "Falowniki" } as const;
|
||||
|
||||
export function Energy() {
|
||||
const [view, show] = useSubView("energy", VIEWS);
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="Energia" subtitle="Falowniki Deye i Fronius, licznik domu" />
|
||||
<SubViewSwitch screen="energy" label="Widok energii" views={VIEWS} view={view} onSelect={show} />
|
||||
<SubViewPanel screen="energy" view={view}>
|
||||
{view === "now" && <Now onBattery={() => show("battery")} />}
|
||||
{view === "battery" && <Battery />}
|
||||
{view === "inverters" && <Devices />}
|
||||
</SubViewPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { Stats } from "./types";
|
||||
|
||||
// A fresh module per test: the poller keeps its answers between screens, not between tests.
|
||||
const historyScreen = async () => (await import("./History")).History;
|
||||
|
||||
const tab = (name: string) => screen.getByRole("tab", { name });
|
||||
|
||||
beforeEach(() => {
|
||||
location.hash = "history";
|
||||
});
|
||||
|
||||
function serveStats(stats: Stats = example("stats")) {
|
||||
return serve({ "api/stats": stats, "api/history": { series: "cwu_temp", unit: "°C", points: [] } });
|
||||
}
|
||||
|
||||
async function open(stats?: Stats) {
|
||||
const fetch = serveStats(stats);
|
||||
location.hash = "history/stats";
|
||||
const History = await historyScreen();
|
||||
render(<History />);
|
||||
return fetch;
|
||||
}
|
||||
|
||||
describe("Historia views", () => {
|
||||
it("starts on Wykresy and keeps Statystyki in the hash, so Back returns", async () => {
|
||||
serveStats();
|
||||
const History = await historyScreen();
|
||||
render(<History />);
|
||||
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Wykresy", "Statystyki"]);
|
||||
expect(tab("Wykresy")).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await userEvent.click(tab("Statystyki"));
|
||||
expect(location.hash).toBe("#history/stats");
|
||||
expect(await screen.findByText("Sprężarka")).toBeInTheDocument();
|
||||
|
||||
history.back();
|
||||
await waitFor(() => expect(tab("Wykresy")).toHaveAttribute("aria-selected", "true"));
|
||||
});
|
||||
|
||||
it("opens Statystyki when the hash names it", async () => {
|
||||
await open();
|
||||
|
||||
expect(tab("Statystyki")).toHaveAttribute("aria-selected", "true");
|
||||
expect(await screen.findByText("Ciepła woda (CWU)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Statystyki", () => {
|
||||
it("shows the totals, the starts as a minimum and the heater highlighted when it ran", async () => {
|
||||
await open();
|
||||
|
||||
expect(await screen.findByText("Załączenia sprężarki, co najmniej")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("0,2 h").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Grzałka").closest("[data-alert]")).toHaveAttribute("data-alert", "true");
|
||||
expect(screen.getByText("Prąd z grzałki jest drogi")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not highlight a heater that stayed off", async () => {
|
||||
const stats = example<Stats>("stats");
|
||||
stats.totals.heaterHours = 0;
|
||||
await open(stats);
|
||||
|
||||
await screen.findByText("Grzałka");
|
||||
expect(screen.getByText("Grzałka").closest("[data-alert]")).toHaveAttribute("data-alert", "false");
|
||||
});
|
||||
|
||||
it("asks for 30 days when chosen", async () => {
|
||||
const fetch = await open();
|
||||
await screen.findByText("Sprężarka");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "30 dni" }));
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining("api/stats?range=30d"), expect.anything()));
|
||||
expect(screen.getByRole("button", { name: "30 dni" })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("warns about cycling above 24 starts a day", async () => {
|
||||
const stats = example<Stats>("stats");
|
||||
stats.days[5].compressorStarts = 25;
|
||||
await open(stats);
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Dużo załączeń – sprężarka może taktować");
|
||||
});
|
||||
|
||||
it("says so when there is no data", async () => {
|
||||
const stats = example<Stats>("stats");
|
||||
stats.days = stats.days.map((d) => ({ ...d, compressorHours: null, compressorStarts: null }));
|
||||
await open(stats);
|
||||
|
||||
expect(await screen.findByText("Za mało danych w tym okresie.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+25
-18
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { number } from "./format";
|
||||
import { usePoll } from "./poll";
|
||||
import { Stats } from "./Stats";
|
||||
import { RangeToggle, SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
|
||||
import { TimeChart, toRows, type Range } from "./TimeChart";
|
||||
import type { History as HistoryData } from "./types";
|
||||
import { Card, Freshness, Loading, PageTitle } from "./ui";
|
||||
@@ -47,29 +49,34 @@ function Chart({ series, range }: { series: SeriesId; range: Range }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function History() {
|
||||
function Charts() {
|
||||
const [series, setSeries] = useState<SeriesId>("cwu_temp");
|
||||
const [range, setRange] = useState<Range>("24h");
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<select value={series} onChange={(e) => setSeries(e.target.value as SeriesId)} aria-label="Wielkość"
|
||||
className="min-h-12 flex-1 rounded-xl border-2 border-edge bg-panel px-3 font-semibold">
|
||||
{Object.entries(SERIES).map(([id, [name]]) => <option key={id} value={id}>{name}</option>)}
|
||||
</select>
|
||||
<RangeToggle label="Okres" options={RANGES} value={range} onSelect={setRange} />
|
||||
</div>
|
||||
<Chart series={series} range={range} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const VIEWS = { charts: "Wykresy", stats: "Statystyki" } as const;
|
||||
|
||||
export function History() {
|
||||
const [view, show] = useSubView("history", VIEWS);
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="Historia" subtitle="Dane z bazy pomiarów" />
|
||||
<Card>
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<select value={series} onChange={(e) => setSeries(e.target.value as SeriesId)} aria-label="Wielkość"
|
||||
className="min-h-12 flex-1 rounded-xl border-2 border-edge bg-panel px-3 font-semibold">
|
||||
{Object.entries(SERIES).map(([id, [name]]) => <option key={id} value={id}>{name}</option>)}
|
||||
</select>
|
||||
<div className="flex gap-2 rounded-xl bg-page p-1">
|
||||
{Object.entries(RANGES).map(([id, name]) => (
|
||||
<button key={id} onClick={() => setRange(id as Range)} aria-pressed={id === range}
|
||||
className="min-h-10 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Chart series={series} range={range} />
|
||||
</Card>
|
||||
<SubViewSwitch screen="history" label="Widok historii" views={VIEWS} view={view} onSelect={show} />
|
||||
<SubViewPanel screen="history" view={view}>
|
||||
{view === "charts" ? <Charts /> : <Stats />}
|
||||
</SubViewPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { HeatPump } from "./types";
|
||||
|
||||
import { PI_SENSOR_TITLE } from "./Overview";
|
||||
const overview = async () => (await import("./Overview")).Overview;
|
||||
const fresh = (hp: HeatPump) => ({ ...hp, time: new Date().toISOString() });
|
||||
const history = { series: "x", unit: "°C", points: [] };
|
||||
@@ -19,29 +18,67 @@ const glowing = (container: HTMLElement) =>
|
||||
[...container.querySelectorAll("[data-glow][data-on=true]")].map((p) => p.getAttribute("data-glow"));
|
||||
|
||||
describe("Overview", () => {
|
||||
it("shows the sensor of the Raspberry Pi in the living room", async () => {
|
||||
serveHeatPump(fresh(example("heat-pump")));
|
||||
it("shows the four key figures once, with the living room as the home temperature", async () => {
|
||||
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
|
||||
"api/pi-sensor": example("pi-sensor"), "api/weather": example("weather") });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
|
||||
expect(card).toHaveTextContent("Temperatura20,2 °C");
|
||||
expect(card).toHaveTextContent("Wilgotność45,8 %");
|
||||
expect(card).toHaveTextContent("Ciśnienie986 hPa");
|
||||
const now = (await screen.findByRole("region", { name: "Teraz" }));
|
||||
const [water, home, outdoor, power] = [...now.children];
|
||||
expect(water).toHaveTextContent("Ciepła woda13,6 °Ccel 8,0 °C");
|
||||
expect(home).toHaveTextContent("W domu20,2 °Cwilgotność 46 %");
|
||||
expect(outdoor).toHaveTextContent("Na zewnątrz4,3 °CPochmurno");
|
||||
expect(power).toHaveTextContent("Prąd");
|
||||
expect(power).toHaveTextContent(/pobór|oddawanie|bez wymiany/);
|
||||
});
|
||||
|
||||
it("says there is no reading when the sensor is stale", async () => {
|
||||
it("falls back to the pump's room sensor when the living room has no reading", async () => {
|
||||
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
|
||||
"api/pi-sensor": example("pi-sensor-stale") });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
|
||||
expect(card).toHaveTextContent("Temperaturabrak odczytu");
|
||||
expect(card).toHaveTextContent("Wilgotnośćbrak odczytu");
|
||||
const home = (await screen.findByRole("region", { name: "Teraz" })).children[1];
|
||||
expect(home).toHaveTextContent("W domu21,2 °Cczujnik pompy");
|
||||
});
|
||||
|
||||
it("lists the living room and the other temperatures once in the details card", async () => {
|
||||
serveHeatPump(fresh(example("heat-pump")));
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: "Szczegóły temperatur" })).closest("section")!;
|
||||
expect(card).toHaveTextContent("Salon20,2 °C");
|
||||
expect(card).toHaveTextContent("Wilgotność w salonie45,8 %");
|
||||
expect(card).toHaveTextContent("Ciśnienie986 hPa");
|
||||
expect(card).toHaveTextContent("Różnica1,7 °C");
|
||||
expect(within(card).getAllByRole("heading", { level: 3 }).map((h) => h.textContent))
|
||||
.toEqual(["Dom", "Woda i ogrzewanie", "Dolne źródło", "Nastawy"]);
|
||||
});
|
||||
|
||||
it("says there is no reading in the details when the sensor is stale", async () => {
|
||||
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
|
||||
"api/pi-sensor": example("pi-sensor-stale") });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: "Szczegóły temperatur" })).closest("section")!;
|
||||
expect(card).toHaveTextContent("Salonbrak odczytu");
|
||||
expect(card).toHaveTextContent("Wilgotność w saloniebrak odczytu");
|
||||
expect(card).toHaveTextContent("Ciśnieniebrak odczytu");
|
||||
});
|
||||
|
||||
it("offers the four devices and a link to the Energia screen", async () => {
|
||||
serveHeatPump(fresh(example("heat-pump")));
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const devices = await screen.findByRole("region", { name: "Stan urządzeń" });
|
||||
expect(devices.children).toHaveLength(4);
|
||||
expect(screen.getByRole("link", { name: "Energia ›" })).toHaveAttribute("href", "#energy");
|
||||
});
|
||||
|
||||
it("shows a running part in the red accent and a stopped one in grey", async () => {
|
||||
const hp = fresh(example<HeatPump>("heat-pump"));
|
||||
serveHeatPump({ ...hp, state: "HEATING_CWU", compressorPercent: 60, pumps: { ...hp.pumps, cwu: true } });
|
||||
|
||||
+83
-105
@@ -1,12 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { celsius, degrees, hour, longDate, number, percent, power } from "./format";
|
||||
import { celsius, hour, longDate, number, percent, power } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { flowing } from "./flow";
|
||||
import { usePoll } from "./poll";
|
||||
import { Schematic } from "./Schematic";
|
||||
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
|
||||
import type { Energy, HeatPump, History, PiSensor, PumpState } from "./types";
|
||||
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
import type { Energy, HeatPump, History, PiSensor, PumpState, Weather } from "./types";
|
||||
import { Card, Dot, Freshness, Loading, PageTitle, Pill } from "./ui";
|
||||
import { EnergyToday, gridText } from "./Energy";
|
||||
import { TemperatureDetails } from "./Details";
|
||||
import { useWeather, WeatherCard, WeatherIcon, WeatherWarnings } from "./Weather";
|
||||
|
||||
const HEADLINES: Record<PumpState, string> = {
|
||||
IDLE: "Pompa odpoczywa",
|
||||
@@ -37,49 +40,76 @@ function StatusPill({ hp }: { hp: HeatPump }) {
|
||||
return hp.state === "IDLE" ? <Pill tone="off">Praca prawidłowa</Pill> : <Pill tone="run" live>{HEADLINES[hp.state]}</Pill>;
|
||||
}
|
||||
|
||||
function Tile({ icon, title, value, note, status, children }: {
|
||||
icon: IconName; title: string; value: ReactNode; note: string; status: ReactNode; children: ReactNode;
|
||||
}) {
|
||||
const running = (on: boolean | null, yes = "Pracuje") =>
|
||||
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
|
||||
|
||||
// One key figure; the number never wraps, so it is sized to fit two across a phone.
|
||||
function Figure({ icon, label, value, note }: { icon: IconName; label: string; value: string; note: ReactNode }) {
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand">
|
||||
<Icon name={icon} className="size-6" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{value}</p>
|
||||
<p className="text-xs text-soft">{note}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-3">{status}</div>
|
||||
<div className="mt-auto text-sm [&>div]:text-sm">{children}</div>
|
||||
<Card className="min-w-0 p-3! sm:p-5!">
|
||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-soft"><Icon name={icon} className="size-4 shrink-0" />{label}</p>
|
||||
<p className="mt-1 whitespace-nowrap text-[1.7rem] font-bold leading-tight tabular-nums tracking-tight sm:text-3xl">{value}</p>
|
||||
<p className="mt-0.5 flex min-h-5 items-center gap-1.5 text-xs text-soft sm:text-sm">{note}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const running = (on: boolean | null, yes = "Pracuje") =>
|
||||
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
|
||||
const gridNote = (watts: number | null | undefined) =>
|
||||
watts == null ? "brak danych o sieci"
|
||||
: flowing(watts) ? `${gridText(watts)} ${power(Math.abs(watts))}` : gridText(watts);
|
||||
|
||||
function Temperatures({ hp }: { hp: HeatPump }) {
|
||||
const t = hp.temperatures;
|
||||
const rows: [IconName, string, number | null, string][] = [
|
||||
["thermometer", "Na zewnątrz", t.outdoor, "text-cold"],
|
||||
["thermometer", "Średnia zewnętrzna", t.outdoorAvg, "text-cold"],
|
||||
["home", "W domu", t.indoor, "text-brand"],
|
||||
["heat", "Bufor CO", t.coBuffer, "text-hot"],
|
||||
["drop", "Ciepła woda (CWU)", t.cwu, "text-hot"],
|
||||
["fan", "Wyjście pompy", t.pumpOutlet, "text-hot"],
|
||||
];
|
||||
// The four figures the family checks first; each value is shown here and nowhere else on the screen.
|
||||
function Now({ hp, e, pi, weather }: { hp: HeatPump; e: Energy | undefined; pi: PiSensor | undefined; weather: Weather | undefined }) {
|
||||
const home = pi?.temperature ?? hp.temperatures.indoor;
|
||||
const humidity = pi?.temperature == null ? null : pi.humidity;
|
||||
return (
|
||||
<Card title="Temperatury">
|
||||
{rows.map(([icon, label, value, colour]) => (
|
||||
<Row key={label} label={label} value={celsius(value)} icon={<span className={colour}><Icon name={icon} /></span>} />
|
||||
))}
|
||||
<section aria-label="Teraz" className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 lg:grid-cols-4">
|
||||
<Figure icon="drop" label="Ciepła woda" value={celsius(hp.temperatures.cwu)} note={`cel ${celsius(hp.targets.cwu)}`} />
|
||||
<Figure icon="home" label="W domu" value={celsius(home)}
|
||||
note={humidity == null ? "czujnik pompy" : `wilgotność ${number(humidity)} %`} />
|
||||
<Figure icon="thermometer" label="Na zewnątrz" value={celsius(hp.temperatures.outdoor)}
|
||||
note={weather && <><WeatherIcon code={weather.today.weatherCode} className="size-5" />{weather.today.description}</>} />
|
||||
<Figure icon="sun" label="Prąd" value={power(e?.pvPowerW)} note={gridNote(e?.gridPowerW)} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// A device on one row: what it is, what it does now, and the one thing only it knows.
|
||||
function Tile({ icon, title, detail, value, status }: {
|
||||
icon: IconName; title: string; detail: string; value?: string; status: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex items-center gap-3 p-3! sm:p-5!">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand">
|
||||
<Icon name={icon} className="size-6" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold">{title}</p>
|
||||
<p className="text-xs text-soft sm:text-sm">{detail}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{value && <p className="text-xl font-bold tabular-nums">{value}</p>}
|
||||
{status}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceTiles({ hp }: { hp: HeatPump }) {
|
||||
return (
|
||||
<section aria-label="Stan urządzeń" className="grid gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
<Tile icon="fan" title="Pompa ciepła" detail={`sprężarka, grzałka ${onOff(hp.heaterOn, "włączona", "wyłączona")}`}
|
||||
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
|
||||
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)} />
|
||||
<Tile icon="tank" title="Zbiornik CWU" detail={`cyrkulacja ${onOff(hp.pumps.circulation, "pracuje", "stoi")}`}
|
||||
status={running(hp.pumps.cwu, "Grzeje wodę")} />
|
||||
<Tile icon="heat" title="Ogrzewanie CO" detail={`pompa bufora ${onOff(hp.pumps.buffer, "pracuje", "stoi")}`}
|
||||
status={running(hp.pumps.co, "Grzeje dom")} />
|
||||
<Tile icon="borehole" title="Odwierty" detail="dolne źródło ciepła" status={running(hp.pumps.ground)} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Every moving part as a chip under the picture: what runs now is red and pulses.
|
||||
function Devices({ hp }: { hp: HeatPump }) {
|
||||
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
|
||||
@@ -106,37 +136,6 @@ function Devices({ hp }: { hp: HeatPump }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Targets({ hp }: { hp: HeatPump }) {
|
||||
const s = hp.setpoints;
|
||||
return (
|
||||
<Card title="Cele i nastawy">
|
||||
<Row label="Pokój, cel teraz" value={celsius(hp.targets.room)} />
|
||||
<Row label="Woda grzewcza CO, cel" value={celsius(hp.targets.co)} />
|
||||
<Row label="Ciepła woda CWU, cel" value={celsius(hp.targets.cwu)} />
|
||||
<Row label="CWU oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
|
||||
<Row label="CO oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// The Raspberry Pi with the BME280 stands in the living room.
|
||||
export const PI_SENSOR_TITLE = "Salon";
|
||||
|
||||
const NO_READING = "brak odczytu";
|
||||
const reading = (value: number | null | undefined, digits: number, unit: string) =>
|
||||
value == null ? NO_READING : `${number(value, digits)} ${unit}`;
|
||||
|
||||
function PiSensorCard() {
|
||||
const { data } = usePoll<PiSensor>("api/pi-sensor");
|
||||
return (
|
||||
<Card title={PI_SENSOR_TITLE}>
|
||||
<Row label="Temperatura" value={reading(data?.temperature, 1, "°C")} />
|
||||
<Row label="Wilgotność" value={reading(data?.humidity, 1, "%")} />
|
||||
<Row label="Ciśnienie" value={reading(data?.pressure, 0, "hPa")} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function useTemperatureHistory() {
|
||||
const cwu = usePoll<History>("api/history?series=cwu_temp&range=24h", HISTORY_MS);
|
||||
const co = usePoll<History>("api/history?series=co_buffer_temp&range=24h", HISTORY_MS);
|
||||
@@ -171,6 +170,8 @@ function NoReading() {
|
||||
export function Overview() {
|
||||
const { data: hp, offline } = usePoll<HeatPump>("api/heat-pump");
|
||||
const { data: e } = usePoll<Energy>("api/energy");
|
||||
const { data: pi } = usePoll<PiSensor>("api/pi-sensor");
|
||||
const weather = useWeather();
|
||||
const now = hp?.time ? new Date(hp.time) : null;
|
||||
const title = (
|
||||
<PageTitle title="Dom"
|
||||
@@ -188,49 +189,26 @@ export function Overview() {
|
||||
<span>ALARM pompy ciepła. {hp.alarmText ? `${hp.alarmText}. ` : ""}Sprawdź sterownik w domu.</span>
|
||||
</div>
|
||||
)}
|
||||
{weather && <WeatherWarnings warnings={weather.warnings} />}
|
||||
{hp.time == null ? <NoReading /> : (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />} className="lg:col-span-2 lg:row-span-2">
|
||||
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
|
||||
<Devices hp={hp} />
|
||||
<p className="mt-3 text-xs text-soft">
|
||||
Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje.
|
||||
</p>
|
||||
</Card>
|
||||
<Temperatures hp={hp} />
|
||||
<Targets hp={hp} />
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 xl:grid-cols-5">
|
||||
<Tile icon="fan" title="Pompa ciepła" note="sprężarka"
|
||||
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
|
||||
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)}>
|
||||
<Row label="Wyjście" value={celsius(hp.temperatures.pumpOutlet)} />
|
||||
<Row label="Grzałka" value={onOff(hp.heaterOn, "włączona", "wyłączona")} />
|
||||
</Tile>
|
||||
<Tile icon="drop" title="Zbiornik CWU" note="ciepła woda" value={celsius(hp.temperatures.cwu)}
|
||||
status={running(hp.pumps.cwu, "Grzeje wodę")}>
|
||||
<Row label="Cel teraz" value={celsius(hp.targets.cwu)} />
|
||||
<Row label="Cyrkulacja" value={onOff(hp.pumps.circulation, "pracuje", "stoi")} />
|
||||
</Tile>
|
||||
<Tile icon="heat" title="Ogrzewanie CO" note="bufor" value={celsius(hp.temperatures.coBuffer)}
|
||||
status={running(hp.pumps.co, "Grzeje dom")}>
|
||||
<Row label="Cel bufora" value={celsius(hp.targets.co)} />
|
||||
<Row label="Pokój" value={celsius(hp.temperatures.indoor)} />
|
||||
</Tile>
|
||||
<Tile icon="borehole" title="Odwierty" note="do pompy" value={celsius(hp.temperatures.groundIn)}
|
||||
status={running(hp.pumps.ground)}>
|
||||
<Row label="Z pompy" value={celsius(hp.temperatures.groundOut)} />
|
||||
<Row label="Różnica" value={hp.temperatures.groundIn != null && hp.temperatures.groundOut != null
|
||||
? celsius(hp.temperatures.groundIn - hp.temperatures.groundOut) : "—"} />
|
||||
</Tile>
|
||||
<Tile icon="sun" title="Prąd teraz" note="produkcja PV" value={power(e?.pvPowerW)}
|
||||
status={e ? <Pill tone={(e.gridPowerW ?? 0) < 0 ? "ok" : "off"}>Sieć: {gridText(e.gridPowerW)}</Pill> : <Pill tone="off">Brak danych</Pill>}>
|
||||
<Row label="Zużycie domu" value={power(e?.loadPowerW)} />
|
||||
<Row label="Bateria" value={percent(e?.batterySocPct)} />
|
||||
</Tile>
|
||||
<Now hp={hp} e={e} pi={pi} weather={weather} />
|
||||
<div className="grid content-start gap-4 lg:col-span-2">
|
||||
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />}>
|
||||
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
|
||||
<Devices hp={hp} />
|
||||
<p className="mt-3 text-xs text-soft">
|
||||
Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje.
|
||||
</p>
|
||||
</Card>
|
||||
<DeviceTiles hp={hp} />
|
||||
</div>
|
||||
<div className="grid content-start gap-4">
|
||||
{weather && <WeatherCard weather={weather} />}
|
||||
<TemperatureDetails hp={hp} pi={pi} />
|
||||
</div>
|
||||
<EnergyToday e={e} link />
|
||||
<TemperatureChart />
|
||||
<PiSensorCard />
|
||||
<EnergyToday e={e} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -14,12 +14,21 @@ const paragraph = (text: string) => (_: string, el: Element | null) => el?.tagNa
|
||||
const posts = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method === "POST");
|
||||
|
||||
describe("Settings", () => {
|
||||
it("describes each quick action in one line", async () => {
|
||||
serve({ "api/setpoints": example("setpoints") });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(await screen.findByText("Ciepła woda – oszczędnie: 45 °C na 2 godz.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Obniża temperatury na czas nieobecności, do dnia powrotu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("writes only after the summary is confirmed", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie"));
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda – oszczędnie"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
expect(screen.getByText("Ustawię 9 °C. Za 2 godz. wrócimy do 8 °C.")).toBeInTheDocument();
|
||||
@@ -35,7 +44,7 @@ describe("Settings", () => {
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Mniej: Ogrzewanie domu, komfort"));
|
||||
await userEvent.click(await screen.findByLabelText("Mniej: Ogrzewanie – komfort"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
|
||||
|
||||
@@ -48,7 +57,7 @@ describe("Settings", () => {
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda – komfort"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
|
||||
|
||||
@@ -61,15 +70,15 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
expect(screen.getByText(paragraph("Ciepła woda – komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
|
||||
expect(posts(fetch)[0][0]).toBe("api/presets/away");
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Tylko część zmian jest zapisana.");
|
||||
expect(alert).toHaveTextContent("Ciepła woda, komfort: zapisano");
|
||||
expect(alert).toHaveTextContent("Ogrzewanie domu, komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
|
||||
expect(alert).toHaveTextContent("Ciepła woda – komfort: zapisano");
|
||||
expect(alert).toHaveTextContent("Ogrzewanie – komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
|
||||
});
|
||||
|
||||
it("asks for the return time of a trip and sends the chosen time with the local offset", async () => {
|
||||
@@ -107,10 +116,10 @@ describe("Settings", () => {
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getAllByRole("status").map((o) => o.textContent)).toEqual(["10 °C", "15 °C"]);
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort"));
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 12 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Mniej: Ogrzewanie – komfort"));
|
||||
expect(screen.getByText(paragraph("Ciepła woda – komfort: 12 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
|
||||
|
||||
const body = JSON.parse(String(posts(fetch)[0][1]!.body));
|
||||
@@ -125,8 +134,8 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByText(paragraph("Ogrzewanie domu, komfort: 14 °C do powrotu, potem wróci 15 °C."))).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie – komfort")).toBeDisabled();
|
||||
expect(screen.getByText(paragraph("Ogrzewanie – komfort: 14 °C do powrotu, potem wróci 15 °C."))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables a trip temperature that cannot be lowered and the confirmation with it", async () => {
|
||||
@@ -137,8 +146,8 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Mniej: Ogrzewanie – komfort")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie – komfort")).toBeDisabled();
|
||||
expect(screen.getByText("Już na minimum (5 °C), nie da się obniżyć.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ustaw wyjazd" })).toBeDisabled();
|
||||
});
|
||||
@@ -189,7 +198,7 @@ describe("Settings", () => {
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
|
||||
expect(await screen.findByText(paragraph("Ciepła woda – komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels a change without an end time as lasting while the server runs", async () => {
|
||||
@@ -200,7 +209,7 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
|
||||
await screen.findByText(paragraph("Ciepła woda – komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -211,13 +220,13 @@ describe("Settings", () => {
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Mniej: Ciepła woda, oszczędnie")).toBeDisabled();
|
||||
expect(await screen.findByLabelText("Więcej: Ciepła woda – oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Ciepła woda teraz (brak odczytu)" })).toBeDisabled();
|
||||
});
|
||||
|
||||
describe("restore now", () => {
|
||||
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda, oszczędnie" });
|
||||
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda – oszczędnie" });
|
||||
const restoreAnswer = (outcome: string, status: number) => () => answer({ outcome, message: "x" }, status);
|
||||
|
||||
it("asks first, then restores and reloads the state", async () => {
|
||||
|
||||
+23
-40
@@ -1,16 +1,17 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { postJson, WriteError } from "./api";
|
||||
import { ChangeHistory } from "./ChangeHistory";
|
||||
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
|
||||
import { reload, usePoll } from "./poll";
|
||||
import type { Change, CommandResult, LineStatus, Preset, RestoreOutcome, RestoreResult, Setpoint, Setpoints } from "./types";
|
||||
import { Card, Freshness, Loading, PageTitle } from "./ui";
|
||||
import { Button, Card, Freshness, Loading, PageTitle, Stepper } from "./ui";
|
||||
|
||||
const URL = "api/setpoints";
|
||||
const NAMES: Record<string, string> = {
|
||||
cwu_economy: "Ciepła woda, oszczędnie",
|
||||
cwu_comfort: "Ciepła woda, komfort",
|
||||
co_economy: "Ogrzewanie domu, oszczędnie",
|
||||
co_comfort: "Ogrzewanie domu, komfort",
|
||||
cwu_economy: "Ciepła woda – oszczędnie",
|
||||
cwu_comfort: "Ciepła woda – komfort",
|
||||
co_economy: "Ogrzewanie – oszczędnie",
|
||||
co_comfort: "Ogrzewanie – komfort",
|
||||
};
|
||||
const DURATIONS = [
|
||||
{ hours: 1, label: "1 godz." }, { hours: 2, label: "2 godz." },
|
||||
@@ -54,36 +55,6 @@ const presetReady = (data: Setpoints, changes: Change[]) => changes.every((c) =>
|
||||
const returnValue = (data: Setpoints, id: string) =>
|
||||
data.restores.find((r) => r.setpoint === id)?.originalValue ?? find(data, id).value;
|
||||
|
||||
function Button({ primary = false, big = false, children, ...props }: {
|
||||
primary?: boolean; big?: boolean; children: ReactNode; onClick: () => void; disabled?: boolean;
|
||||
"aria-label"?: string; "aria-pressed"?: boolean;
|
||||
}) {
|
||||
const look = primary
|
||||
? "border-brand bg-brand text-white dark:text-side"
|
||||
: "border-brand bg-panel text-brand aria-pressed:bg-brand aria-pressed:text-white dark:aria-pressed:text-side";
|
||||
return (
|
||||
<button {...props}
|
||||
className={`flex-1 rounded-xl border-2 px-3 font-bold transition active:scale-[0.98] disabled:opacity-40 ${look} ${big ? "min-h-16 text-lg" : "min-h-12"}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Touch-sized − and + around the value; no value shows "brak odczytu".
|
||||
function Stepper({ label, value, min, max, disabled, onStep }: {
|
||||
label: string; value: number | null | undefined; min: number; max: number; disabled?: boolean; onStep: (direction: number) => void;
|
||||
}) {
|
||||
const none = value == null;
|
||||
const button = "size-16 rounded-xl border-2 border-brand bg-panel text-3xl font-bold text-brand disabled:opacity-40";
|
||||
return (
|
||||
<div className="my-2 flex items-center gap-3">
|
||||
<button aria-label={`Mniej: ${label}`} onClick={() => onStep(-1)} disabled={disabled || none || value <= min} className={button}>−</button>
|
||||
<output className="flex-1 text-center text-3xl font-bold tabular-nums">{none ? "brak odczytu" : degrees(value)}</output>
|
||||
<button aria-label={`Więcej: ${label}`} onClick={() => onStep(1)} disabled={disabled || none || value >= max} className={button}>+</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultBox({ result }: { result: Result }) {
|
||||
if (!result) return null;
|
||||
const ok = result.status === "APPLIED";
|
||||
@@ -159,7 +130,7 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
|
||||
return (
|
||||
<div key={c.setpoint} className="my-3">
|
||||
<span className="block font-semibold">{label}</span>
|
||||
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} disabled={busy || max < min}
|
||||
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} format={degrees} disabled={busy || max < min}
|
||||
onStep={(direction) => stepValue(c.setpoint, direction)} />
|
||||
{max < min && <p className="text-sm text-alarm">Już na minimum ({degrees(find(data, c.setpoint).value)}), nie da się obniżyć.</p>}
|
||||
</div>
|
||||
@@ -173,6 +144,11 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
|
||||
);
|
||||
}
|
||||
|
||||
// What a preset does, in one line under its button; a held preset has no fixed values (the user picks them).
|
||||
const presetHint = (p: Preset) => p.until
|
||||
? "Obniża temperatury na czas nieobecności, do dnia powrotu"
|
||||
: p.changes.map((c) => `${name(c.setpoint)}: ${degrees(c.value)}${c.hours == null ? " do odwołania" : ` na ${duration(c.hours)}`}`).join(", ");
|
||||
|
||||
function changeSentence(data: Setpoints, c: Change, held: boolean) {
|
||||
const back = degrees(returnValue(data, c.setpoint));
|
||||
const tail = held ? `do powrotu, potem wróci ${back}`
|
||||
@@ -313,22 +289,28 @@ export function Settings() {
|
||||
{data.presets.map((p) => {
|
||||
const ready = presetReady(data, p.changes);
|
||||
return (
|
||||
<Button key={p.id} big onClick={() => { setResult(null); setPresetId(p.id); }} disabled={busy || !ready}>
|
||||
{p.name}{ready ? "" : " (brak odczytu)"}
|
||||
</Button>
|
||||
<div key={p.id} className="flex flex-col gap-1.5">
|
||||
<Button big onClick={() => { setResult(null); setPresetId(p.id); }} disabled={busy || !ready}>
|
||||
{p.name}{ready ? "" : " (brak odczytu)"}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-soft">{presetHint(p)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card title="Nastawy">
|
||||
<p className="mb-3 text-sm text-soft">
|
||||
„Komfort” i „oszczędnie” to dwie temperatury, między którymi pompa przełącza się sama według tygodniowego programu na sterowniku.
|
||||
</p>
|
||||
{data.setpoints.map((s) => {
|
||||
const shown = draft?.name === s.name ? draft.value : s.value;
|
||||
const label = name(s.name);
|
||||
return (
|
||||
<div key={s.name} className={`border-t border-edge py-3 first:border-t-0 ${shown === s.value ? "" : "border-l-4 border-l-brand pl-3"}`}>
|
||||
<span className="block font-semibold">{label}</span>
|
||||
<Stepper label={label} value={unread(s) ? null : shown} min={s.min} max={s.max} disabled={busy} onStep={(direction) => step(s, direction)} />
|
||||
<Stepper label={label} value={unread(s) ? null : shown} min={s.min} max={s.max} format={degrees} disabled={busy} onStep={(direction) => step(s, direction)} />
|
||||
<span className="text-sm text-soft">od {degrees(s.min)} do {degrees(s.max)}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -360,6 +342,7 @@ export function Settings() {
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
<ChangeHistory />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from "react";
|
||||
import { Bar, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { DASH, degrees, number } from "./format";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Stats as StatsData, StatsDay } from "./types";
|
||||
import { RangeToggle } from "./SubView";
|
||||
import { Card, Freshness, Loading } from "./ui";
|
||||
|
||||
const RANGES = { "7d": "7 dni", "30d": "30 dni" } as const;
|
||||
type RangeId = keyof typeof RANGES;
|
||||
|
||||
const STATS_MS = 5 * 60000; // the backend reads every raw heat pump row of the range for each request
|
||||
const TAKTING_STARTS_PER_DAY = 24;
|
||||
const BAR = "#0f8a5f";
|
||||
const TEMPERATURE = "#3b82f6";
|
||||
|
||||
const hours = (value: number | null) => (value == null ? DASH : `${number(value, 1)} h`);
|
||||
|
||||
// "2026-10-24" as "sb 24"; noon UTC keeps the date whatever the viewer's zone.
|
||||
function dayLabel(date: string) {
|
||||
const noon = new Date(`${date}T12:00:00Z`);
|
||||
const weekday = noon.toLocaleDateString("pl-PL", { weekday: "short", timeZone: "UTC" });
|
||||
return `${weekday} ${noon.getUTCDate()}`;
|
||||
}
|
||||
|
||||
/** Days with more starts than this suggest short cycling. */
|
||||
export function takting(days: StatsDay[]) {
|
||||
return days.some((d) => (d.compressorStarts ?? 0) > TAKTING_STARTS_PER_DAY);
|
||||
}
|
||||
|
||||
function Tile({ label, value, hint, alert = false, wide = false }: {
|
||||
label: string; value: string; hint?: string; alert?: boolean; wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div data-alert={alert} className={`rounded-xl border p-3 ${wide ? "col-span-2 lg:col-span-1" : ""} ${alert ? "border-alarm bg-run-soft" : "border-edge bg-page"}`}>
|
||||
<p className="text-2xl font-bold tabular-nums">{value}</p>
|
||||
<p className="text-sm text-soft">{label}</p>
|
||||
{hint && <p className="text-sm text-soft">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Totals({ stats }: { stats: StatsData }) {
|
||||
const t = stats.totals;
|
||||
return (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<Tile label="Sprężarka" value={hours(t.compressorHours)} />
|
||||
<Tile label="Załączenia sprężarki, co najmniej" value={number(t.compressorStarts)} />
|
||||
<Tile label="Ciepła woda (CWU)" value={hours(t.cwuHours)} />
|
||||
<Tile label="Ogrzewanie (CO)" value={hours(t.coHours)} />
|
||||
<Tile label="Grzałka" value={hours(t.heaterHours)} wide alert={t.heaterHours > 0}
|
||||
hint={t.heaterHours > 0 ? "Prąd z grzałki jest drogi" : undefined} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLegend() {
|
||||
return (
|
||||
<ul className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-soft">
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="size-3 rounded-sm" style={{ background: BAR }} aria-hidden="true" />słupki: godziny pracy sprężarki
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="h-1 w-4 rounded-full" style={{ background: TEMPERATURE }} aria-hidden="true" />linia: średnia temp. zewnętrzna
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function DailyChart({ days }: { days: StatsDay[] }) {
|
||||
const rows = days.map((d) => ({ label: dayLabel(d.date), hours: d.compressorHours, outdoor: d.avgOutdoorTemp }));
|
||||
return (
|
||||
<div style={{ height: 280 }} className="w-full" role="img" aria-label="Godziny pracy sprężarki w kolejnych dniach">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={rows} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid stroke="var(--color-edge)" vertical={false} />
|
||||
<XAxis dataKey="label" stroke="var(--color-soft)" fontSize={12} tickLine={false} minTickGap={12} />
|
||||
<YAxis yAxisId="hours" stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={44}
|
||||
domain={[0, (max: number) => Math.ceil(max / 2) * 2]} tickCount={5} allowDecimals={false}
|
||||
tickFormatter={(v: number) => number(v, 0)}
|
||||
label={{ value: "godz. pracy (h)", angle: -90, position: "insideLeft", offset: 4, fill: "var(--color-soft)", fontSize: 12 }} />
|
||||
<YAxis yAxisId="temperature" orientation="right" stroke="var(--color-soft)" fontSize={12} tickLine={false}
|
||||
axisLine={false} width={44} allowDecimals={false} tickFormatter={(v: number) => number(v, 0)}
|
||||
label={{ value: "°C", angle: 90, position: "insideRight", offset: 4, fill: "var(--color-soft)", fontSize: 12 }} />
|
||||
<Tooltip
|
||||
formatter={(v, name) => [name === "Średnia temperatura zewnętrzna" ? degrees(Number(v)) : hours(Number(v)), name]}
|
||||
contentStyle={{ background: "var(--color-panel)", border: "1px solid var(--color-edge)", borderRadius: 12 }}
|
||||
/>
|
||||
<Bar yAxisId="hours" dataKey="hours" name="Sprężarka (godziny)" fill={BAR} radius={[4, 4, 0, 0]} isAnimationActive={false} />
|
||||
<Line yAxisId="temperature" dataKey="outdoor" name="Średnia temperatura zewnętrzna" stroke={TEMPERATURE}
|
||||
strokeWidth={2.5} dot={false} isAnimationActive={false} connectNulls={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsFor({ range }: { range: RangeId }) {
|
||||
// One poller per range, so an answer is never shown for the other one.
|
||||
const { data, offline } = usePoll<StatsData>(`api/stats?range=${range}`, STATS_MS);
|
||||
if (!data) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
const withData = data.days.filter((d) => d.compressorHours != null);
|
||||
return (
|
||||
<>
|
||||
{offline && <Freshness offline />}
|
||||
{withData.length === 0 ? <p className="py-8 text-soft">Za mało danych w tym okresie.</p> : (
|
||||
<>
|
||||
<Totals stats={data} />
|
||||
{takting(data.days) && (
|
||||
<p role="status" className="mb-4 rounded-xl bg-run-soft p-3 text-sm text-run">
|
||||
Dużo załączeń – sprężarka może taktować, czyli często się włączać i wyłączać na krótko. To zużywa ją szybciej.
|
||||
</p>
|
||||
)}
|
||||
<ChartLegend />
|
||||
<DailyChart days={data.days} />
|
||||
<p className="mt-2 text-sm text-soft">
|
||||
Wartości liczone z pomiarów co 5 minut, więc załączenia są policzone „co najmniej”: krótkie cykle między pomiarami są niewidoczne.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stats() {
|
||||
const [range, setRange] = useState<RangeId>("7d");
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-4"><RangeToggle label="Okres" options={RANGES} value={range} onSelect={setRange} /></div>
|
||||
<StatsFor range={range} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -27,4 +27,27 @@ describe("Status", () => {
|
||||
expect(versions()).toHaveTextContent("Bramkanieznana");
|
||||
expect(versions()).toHaveTextContent("Serwerv1.3.0");
|
||||
});
|
||||
|
||||
it("does not call a device working when its last reading is old", async () => {
|
||||
const health = example<Health>("health");
|
||||
const pump = health.devices[0];
|
||||
health.devices[0] = { ...pump, lastSuccess: new Date(Date.now() - 17 * 3600_000).toISOString() };
|
||||
serve({ "api/health": health });
|
||||
const Status = await status();
|
||||
render(<Status />);
|
||||
|
||||
expect(await screen.findByText("brak świeżych danych")).toBeInTheDocument();
|
||||
expect(screen.queryByText("działa")).toBeNull();
|
||||
});
|
||||
|
||||
it("says a device works when it answered a moment ago", async () => {
|
||||
const health = example<Health>("health");
|
||||
health.devices[0] = { ...health.devices[0], lastSuccess: new Date().toISOString() };
|
||||
serve({ "api/health": health });
|
||||
const Status = await status();
|
||||
render(<Status />);
|
||||
|
||||
expect(await screen.findByText("działa")).toBeInTheDocument();
|
||||
expect(screen.queryByText("brak świeżych danych")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ago } from "./format";
|
||||
import { Icon } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Health } from "./types";
|
||||
import { Card, Dot, Freshness, Loading, PageTitle, Row } from "./ui";
|
||||
import { Card, Dot, Freshness, isStale, Loading, PageTitle, Row } from "./ui";
|
||||
|
||||
const DEVICE_NAMES: Record<string, string> = {
|
||||
"heat-pump": "Pompa ciepła",
|
||||
@@ -30,11 +31,15 @@ export function Status() {
|
||||
{data.devices.length === 0 ? <p className="text-soft">Lista pojawi się, gdy dom odpowie.</p>
|
||||
: data.devices.map((d) => {
|
||||
const last = d.lastSuccess ? `ostatni odczyt ${ago(d.lastSuccess)}` : "brak odczytu";
|
||||
// "Up" says the last try worked; a reading that old is no longer news.
|
||||
const old = d.up && d.lastSuccess != null && isStale(d.lastSuccess);
|
||||
return (
|
||||
<Row key={d.name} icon={<Dot tone={d.up ? "ok" : "bad"} />}
|
||||
<Row key={d.name} icon={<Dot tone={d.up ? (old ? "warn" : "ok") : "bad"} />}
|
||||
label={<span><span className="text-ink">{DEVICE_NAMES[d.name] ?? d.name}</span><br />
|
||||
<span className="text-xs">{d.up ? last : `nie odpowiada, ${last}`}</span></span>}
|
||||
value={<span className={d.up ? "text-brand" : "text-alarm"}>{d.up ? "działa" : "brak"}</span>} />
|
||||
value={d.up ? (old ? <span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
||||
<Icon name="alert" className="size-4" />brak świeżych danych</span> : <span className="text-brand">działa</span>)
|
||||
: <span className="text-alarm">brak</span>} />
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Views inside one screen, kept in the URL hash so Back works: the first view is `#screen`,
|
||||
* the others `#screen/id`. Every view is a history entry. `views` must be a constant.
|
||||
*/
|
||||
export function useSubView<T extends string>(screen: string, views: Record<T, string>) {
|
||||
const ids = Object.keys(views) as T[];
|
||||
const fromHash = (): T => {
|
||||
const [hashScreen, view = ""] = location.hash.slice(1).split("/");
|
||||
return hashScreen === screen && view in views ? (view as T) : ids[0];
|
||||
};
|
||||
const [view, setView] = useState(fromHash);
|
||||
useEffect(() => {
|
||||
const onHash = () => setView(fromHash());
|
||||
addEventListener("hashchange", onHash);
|
||||
return () => removeEventListener("hashchange", onHash);
|
||||
}, [screen, views]);
|
||||
const show = (id: T) => {
|
||||
location.hash = id === ids[0] ? screen : `${screen}/${id}`;
|
||||
};
|
||||
return [view, show] as const;
|
||||
}
|
||||
|
||||
export function SubViewSwitch<T extends string>({ screen, label, views, view, onSelect }: {
|
||||
screen: string; label: string; views: Record<T, string>; view: T; onSelect: (id: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="tablist" aria-label={label} className="mb-5 flex gap-1 rounded-xl border border-edge bg-panel p-1 lg:max-w-md">
|
||||
{(Object.entries(views) as [T, string][]).map(([id, name]) => (
|
||||
<button key={id} role="tab" id={`${screen}-tab-${id}`} aria-selected={id === view} aria-controls={`${screen}-view`}
|
||||
onClick={() => onSelect(id)}
|
||||
className="min-h-11 flex-1 rounded-lg px-3 font-semibold text-soft focus-visible:outline-2 focus-visible:outline-brand aria-selected:bg-brand aria-selected:text-white dark:aria-selected:text-black">
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubViewPanel({ screen, view, children }: { screen: string; view: string; children: ReactNode }) {
|
||||
return <div role="tabpanel" id={`${screen}-view`} aria-labelledby={`${screen}-tab-${view}`}>{children}</div>;
|
||||
}
|
||||
|
||||
/** Two or three choices of one setting (a range); the chosen one is filled so it reads at a glance. */
|
||||
export function RangeToggle<T extends string>({ label, options, value, onSelect }: {
|
||||
label: string; options: Record<T, string>; value: T; onSelect: (id: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="flex w-full gap-1 rounded-xl border border-edge bg-page p-1 sm:w-fit">
|
||||
{(Object.entries(options) as [T, string][]).map(([id, name]) => (
|
||||
<button key={id} onClick={() => onSelect(id)} aria-pressed={id === value}
|
||||
className="min-h-11 min-w-28 flex-1 whitespace-nowrap rounded-lg px-4 font-semibold text-soft focus-visible:outline-2 focus-visible:outline-brand aria-pressed:bg-brand aria-pressed:text-white dark:aria-pressed:text-black">
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { timeTicks, valueTicks } from "./TimeChart";
|
||||
|
||||
const HOUR = 3600000;
|
||||
const start = Date.parse("2026-10-23T00:00:00+02:00");
|
||||
|
||||
describe("timeTicks", () => {
|
||||
it("puts 4 to 6 ticks on a day, at round hours", () => {
|
||||
const ticks = timeTicks(start + HOUR / 2, start + 24 * HOUR, "24h");
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(4);
|
||||
expect(ticks.length).toBeLessThanOrEqual(6);
|
||||
expect(ticks.map((t) => ((t - start) / HOUR) % 6)).toEqual(ticks.map(() => 0));
|
||||
});
|
||||
|
||||
it("uses finer steps for a short span", () => {
|
||||
expect(timeTicks(start, start + 2 * HOUR, "24h")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("puts a tick on every midnight of 7 days (the clocks change on the 25th)", () => {
|
||||
expect(timeTicks(start + HOUR, start + 7 * 24 * HOUR, "7d")).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("valueTicks", () => {
|
||||
it("hugs the data with round steps", () => {
|
||||
expect(valueTicks(14.1, 44.2)).toEqual([10, 20, 30, 40, 50]);
|
||||
});
|
||||
|
||||
it("copes with flat data", () => {
|
||||
expect(valueTicks(5, 5)).toEqual([4, 6]);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import type { Point } from "./types";
|
||||
export type Range = "24h" | "7d";
|
||||
|
||||
const HOUR_MS = 3600000;
|
||||
const HOURS_BETWEEN_TICKS = 6;
|
||||
const GAP_FACTOR = 3; // of the median step between points
|
||||
const DAY_NAMES = ["nd", "pn", "wt", "śr", "cz", "pt", "sb"];
|
||||
|
||||
@@ -34,14 +33,28 @@ export function toRows(series: Record<string, Point[]>): Row[] {
|
||||
return sorted.flatMap((row, i) => (i && row.t - sorted[i - 1].t > limit ? [{ t: row.t - 1 }, row] : [row]));
|
||||
}
|
||||
|
||||
// Warsaw midnights (or every 6th full Warsaw hour) inside the range, so the axis reads like a clock.
|
||||
const HOUR_STEPS = [1, 2, 3, 6, 12];
|
||||
const MAX_TICKS = 6;
|
||||
|
||||
// Warsaw midnights (7 days) or the finest whole-hour step with at most 6 ticks, so the axis reads like a clock.
|
||||
export function timeTicks(t0: number, t1: number, range: Range): number[] {
|
||||
const ticks = [];
|
||||
for (let t = Math.ceil(t0 / HOUR_MS) * HOUR_MS; t <= t1; t += HOUR_MS) {
|
||||
const h = warsaw(t).hour;
|
||||
if (range === "7d" ? h === 0 : h % HOURS_BETWEEN_TICKS === 0) ticks.push(t);
|
||||
}
|
||||
return ticks;
|
||||
const wholeHours: { t: number; h: number }[] = [];
|
||||
for (let t = Math.ceil(t0 / HOUR_MS) * HOUR_MS; t <= t1; t += HOUR_MS) wholeHours.push({ t, h: warsaw(t).hour });
|
||||
if (range === "7d") return wholeHours.filter(({ h }) => h === 0).map(({ t }) => t);
|
||||
const ticksAt = (step: number) => wholeHours.filter(({ h }) => h % step === 0).map(({ t }) => t);
|
||||
const step = HOUR_STEPS.find((s) => ticksAt(s).length <= MAX_TICKS) ?? HOUR_STEPS[HOUR_STEPS.length - 1];
|
||||
return ticksAt(step);
|
||||
}
|
||||
|
||||
/** Round values around the data (steps of 1, 2 or 5 times a power of ten), about 4 intervals. */
|
||||
export function valueTicks(min: number, max: number): number[] {
|
||||
const span = max - min || Math.abs(max) || 1;
|
||||
const rough = span / 4;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rough));
|
||||
const step = ([1, 2, 5, 10].find((m) => m * magnitude >= rough) ?? 10) * magnitude;
|
||||
const first = Math.floor(min / step);
|
||||
const last = Math.ceil(max / step);
|
||||
return Array.from({ length: last - first + 1 }, (_, i) => Number(((first + i) * step).toPrecision(12)));
|
||||
}
|
||||
|
||||
export interface Line {
|
||||
@@ -56,6 +69,8 @@ export function TimeChart({ rows, lines, unit, range, height = 260, area = false
|
||||
}) {
|
||||
const times = rows.map((r) => r.t);
|
||||
const ticks = timeTicks(Math.min(...times), Math.max(...times), range);
|
||||
const values = rows.flatMap((r) => lines.map((l) => r[l.key])).filter((v): v is number => v != null);
|
||||
const valueAxis = valueTicks(Math.min(...values), Math.max(...values));
|
||||
const digits = unit === "°C" ? 1 : 0;
|
||||
const tick = (t: number) => (range === "7d" ? DAY_NAMES[warsaw(t + 1).weekday] : hour(new Date(t)));
|
||||
return (
|
||||
@@ -74,7 +89,8 @@ export function TimeChart({ rows, lines, unit, range, height = 260, area = false
|
||||
<XAxis dataKey="t" type="number" scale="time" domain={["dataMin", "dataMax"]} ticks={ticks}
|
||||
tickFormatter={tick} stroke="var(--color-soft)" fontSize={12} tickLine={false} />
|
||||
<YAxis stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={48}
|
||||
domain={["auto", "auto"]} tickFormatter={(v: number) => number(v, 0)} />
|
||||
ticks={valueAxis} domain={[valueAxis[0], valueAxis[valueAxis.length - 1]]}
|
||||
tickFormatter={(v: number) => number(v, valueAxis.every(Number.isInteger) ? 0 : 1)} />
|
||||
<Tooltip
|
||||
labelFormatter={(t) => dayAndHour(new Date(Number(t)))}
|
||||
formatter={(v, name) => [`${number(Number(v), digits)} ${unit}`, name]}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { HeatPump, Weather } from "./types";
|
||||
|
||||
const weather = () => example<Weather>("weather");
|
||||
const warning = (event: string, level: number) => ({ ...weather().warnings[0], event, level });
|
||||
const history = { series: "x", unit: "°C", points: [] };
|
||||
|
||||
async function overview(patch: Partial<Weather> = {}) {
|
||||
serve({ "api/heat-pump": { ...example<HeatPump>("heat-pump"), time: new Date().toISOString() },
|
||||
"api/energy": example("energy"), "api/history": history, "api/pi-sensor": example("pi-sensor"),
|
||||
"api/weather": { ...weather(), ...patch } });
|
||||
const { Overview } = await import("./Overview");
|
||||
render(<Overview />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
location.hash = "";
|
||||
});
|
||||
|
||||
describe("Pogoda card", () => {
|
||||
it("shows three days with temperatures, rain and the PV estimate", async () => {
|
||||
await overview();
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: "Pogoda" })).closest("section")!;
|
||||
const [today, tomorrow, dayAfter] = within(card).getAllByRole("listitem");
|
||||
expect(today).toHaveTextContent("Dziś");
|
||||
expect(today).toHaveTextContent("Pochmurno");
|
||||
expect(today).toHaveTextContent("10,9 °C / 3,8 °C");
|
||||
expect(today).toHaveTextContent("Opady: 0,0 mm");
|
||||
expect(today).toHaveTextContent("PV ok. 18 kWh");
|
||||
expect(tomorrow).toHaveTextContent("Jutro");
|
||||
expect(tomorrow).toHaveTextContent("Opady: 0,3 mm");
|
||||
expect(dayAfter).toHaveTextContent("Pojutrze");
|
||||
expect(dayAfter).toHaveTextContent("PV ok. 4 kWh");
|
||||
});
|
||||
|
||||
it("draws the picture of the weather code", async () => {
|
||||
await overview();
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: "Pogoda" })).closest("section")!;
|
||||
expect(within(card).getAllByRole("img").map((i) => i.getAttribute("aria-label"))).toEqual(["chmury", "deszcz", "deszcz"]);
|
||||
});
|
||||
|
||||
it("leaves out the PV estimate when the backend has none", async () => {
|
||||
const w = weather();
|
||||
await overview({ pvFactor: null, today: { ...w.today, pvForecastKwh: null } });
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: "Pogoda" })).closest("section")!;
|
||||
expect(within(card).getAllByText(/PV ok\./)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("says when the weather is older than it should be", async () => {
|
||||
await overview({ stale: true });
|
||||
|
||||
expect(await screen.findByText(/pokazuję starsze dane/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is absent without an answer from the weather API", async () => {
|
||||
serve({ "api/heat-pump": { ...example<HeatPump>("heat-pump"), time: new Date().toISOString() },
|
||||
"api/energy": example("energy"), "api/history": history, "api/pi-sensor": example("pi-sensor") });
|
||||
const { Overview } = await import("./Overview");
|
||||
render(<Overview />);
|
||||
|
||||
await screen.findByRole("heading", { name: "Szczegóły temperatur" });
|
||||
expect(screen.queryByRole("heading", { name: "Pogoda" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("IMGW warnings on the overview", () => {
|
||||
it("shows each warning as one line with its degree and period, and the text on a tap", async () => {
|
||||
await overview();
|
||||
|
||||
const line = await screen.findByText(/Ostrzeżenie IMGW, stopień 2: Silny wiatr/);
|
||||
const banner = line.closest("details")!;
|
||||
expect(banner).toHaveClass("border-orange-500");
|
||||
expect(banner).not.toHaveAttribute("open");
|
||||
await userEvent.click(line);
|
||||
expect(banner).toHaveAttribute("open");
|
||||
expect(banner).toHaveTextContent("Prognozuje się wystąpienie silnego wiatru");
|
||||
});
|
||||
|
||||
it("colours the degrees yellow, orange and red", async () => {
|
||||
await overview({ warnings: [warning("Mróz", 1), warning("Upał", 2), warning("Burza", 3)] });
|
||||
|
||||
await screen.findByText(/stopień 3: Burza/);
|
||||
expect(screen.getByText(/stopień 1: Mróz/).closest("details")).toHaveClass("border-yellow-500");
|
||||
expect(screen.getByText(/stopień 2: Upał/).closest("details")).toHaveClass("border-orange-500");
|
||||
expect(screen.getByText(/stopień 3: Burza/).closest("details")).toHaveClass("border-alarm");
|
||||
});
|
||||
|
||||
it("shows one banner for identical warnings and keeps ones that differ only in their end", async () => {
|
||||
const w = warning("Silny wiatr", 1);
|
||||
await overview({ warnings: [w, { ...w }, { ...w, to: "2026-10-26T18:00:00+01:00" }] });
|
||||
|
||||
await screen.findByRole("heading", { name: "Pogoda" });
|
||||
expect(screen.getAllByText(/stopień 1: Silny wiatr/)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows no banner without warnings", async () => {
|
||||
await overview({ warnings: [] });
|
||||
|
||||
await screen.findByRole("heading", { name: "Pogoda" });
|
||||
expect(screen.queryByText(/Ostrzeżenie IMGW/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("IMGW hint on the battery", () => {
|
||||
async function battery(warnings: Weather["warnings"]) {
|
||||
serve({ "api/battery": example("battery"), "api/weather": { ...weather(), warnings } });
|
||||
const { Battery } = await import("./Battery");
|
||||
render(<Battery />);
|
||||
await screen.findByText("Bateria teraz");
|
||||
}
|
||||
|
||||
const HINT = "Zapowiadany silny wiatr lub burza – zostaw baterię naładowaną (rezerwa na awarię).";
|
||||
|
||||
it("suggests keeping the battery full for strong wind", async () => {
|
||||
await battery([warning("Silny wiatr", 1)]);
|
||||
|
||||
expect(await screen.findByText(HINT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("suggests it for a thunderstorm too", async () => {
|
||||
await battery([warning("Burze z gradem", 2)]);
|
||||
|
||||
expect(await screen.findByText(HINT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays quiet for other warnings", async () => {
|
||||
await battery([warning("Upał", 2)]);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(screen.queryByText(HINT)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { celsius, dateTime, number } from "./format";
|
||||
import { Icon } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Weather, WeatherDay, WeatherWarning } from "./types";
|
||||
import { Card } from "./ui";
|
||||
|
||||
const WEATHER_MS = 5 * 60000; // the backend keeps the forecast 30 min and the warnings 10 min
|
||||
|
||||
export const useWeather = () => usePoll<Weather>("api/weather", WEATHER_MS).data;
|
||||
|
||||
type Kind = "sun" | "partly" | "cloud" | "fog" | "rain" | "snow" | "storm";
|
||||
|
||||
// WMO weather interpretation codes grouped by what the picture shows.
|
||||
function kindOf(code: number | null): Kind | null {
|
||||
if (code == null) return null;
|
||||
if (code <= 1) return "sun";
|
||||
if (code === 2) return "partly";
|
||||
if (code === 3) return "cloud";
|
||||
if (code === 45 || code === 48) return "fog";
|
||||
if (code >= 95) return "storm";
|
||||
if ((code >= 71 && code <= 77) || code === 85 || code === 86) return "snow";
|
||||
return "rain";
|
||||
}
|
||||
|
||||
const CLOUD = "M7 18a4 4 0 0 1-.6-7.96A5.5 5.5 0 0 1 17 9.5a4.2 4.2 0 0 1 0 8.5z";
|
||||
const SUN = "M12 15a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 11.5h2M20 11.5h2M4.9 18.1l1.4-1.4M17.7 5.3l1.4-1.4";
|
||||
|
||||
const PICTURES: Record<Kind, { label: string; paths: string[]; colour: string }> = {
|
||||
sun: { label: "słońce", paths: [SUN], colour: "text-amber-500" },
|
||||
partly: { label: "słońce za chmurami", paths: ["M9 3v1.5M3.6 5.6l1.1 1.1M2 11h1.5M14.4 5.6l-1.1 1.1", "M7 20a3.5 3.5 0 0 1-.4-7A4.8 4.8 0 0 1 16 12.5a3.7 3.7 0 0 1 0 7.5z"], colour: "text-amber-500" },
|
||||
cloud: { label: "chmury", paths: [CLOUD], colour: "text-soft" },
|
||||
fog: { label: "mgła", paths: ["M4 9h16M3 13h18M5 17h14M8 21h8"], colour: "text-soft" },
|
||||
rain: { label: "deszcz", paths: [CLOUD, "M8 21l1-2M12 21l1-2M16 21l1-2"], colour: "text-cold" },
|
||||
snow: { label: "śnieg", paths: [CLOUD, "M8 20.5h.01M12 21h.01M16 20.5h.01M10 23h.01M14 23h.01"], colour: "text-cold" },
|
||||
storm: { label: "burza", paths: [CLOUD, "M12.5 14l-2.5 4h3l-1.5 4"], colour: "text-alarm" },
|
||||
};
|
||||
|
||||
export function WeatherIcon({ code, className = "size-10" }: { code: number | null; className?: string }) {
|
||||
const kind = kindOf(code);
|
||||
if (!kind) return <span className={`grid shrink-0 place-items-center text-soft ${className}`}>—</span>;
|
||||
const { label, paths, colour } = PICTURES[kind];
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" role="img" aria-label={label} className={`shrink-0 ${className} ${colour}`} fill="none"
|
||||
stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
|
||||
{paths.map((d) => <path key={d} d={d} />)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const DAY_NAMES = ["Dziś", "Jutro", "Pojutrze"];
|
||||
|
||||
function Day({ name, day }: { name: string; day: WeatherDay }) {
|
||||
const rain = day.precipitationMm;
|
||||
return (
|
||||
<li className="flex items-center gap-3 border-t border-edge py-2.5 first:border-t-0 first:pt-0 last:pb-0">
|
||||
<WeatherIcon code={day.weatherCode} className="size-9" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p><b>{name}</b> <span className="text-soft">{day.description}</span></p>
|
||||
<p className="flex flex-wrap gap-x-3 text-xs text-soft">
|
||||
<span className="whitespace-nowrap">Opady: {rain == null ? "—" : `${number(rain, 1)} mm`}</span>
|
||||
{day.pvForecastKwh != null && <b className="whitespace-nowrap font-semibold text-brand">PV ok. {number(day.pvForecastKwh)} kWh</b>}
|
||||
</p>
|
||||
</div>
|
||||
<p className="whitespace-nowrap tabular-nums"><b>{celsius(day.tMax)}</b> / {celsius(day.tMin)}</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeatherCard({ weather }: { weather: Weather }) {
|
||||
const days = [weather.today, weather.tomorrow, weather.dayAfter];
|
||||
return (
|
||||
<Card title="Pogoda">
|
||||
<ul>
|
||||
{days.map((day, i) => <Day key={day.date} name={DAY_NAMES[i]} day={day} />)}
|
||||
</ul>
|
||||
{weather.stale && <p className="mt-3 text-xs text-soft">Nie udało się odświeżyć pogody, pokazuję starsze dane.</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const LEVELS: Record<number, string> = {
|
||||
1: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950/60 dark:text-yellow-200",
|
||||
2: "border-orange-500 bg-orange-50 text-orange-900 dark:bg-orange-950/60 dark:text-orange-200",
|
||||
3: "border-alarm bg-red-50 text-alarm dark:bg-red-950/60 dark:text-red-200",
|
||||
};
|
||||
|
||||
/** The IMGW warnings, one line each (degree colour, event, period); the text is IMGW's own and opens on a tap. */
|
||||
export function WeatherWarnings({ warnings }: { warnings: WeatherWarning[] }) {
|
||||
// IMGW sends one warning per area, so the same one can arrive twice.
|
||||
const key = (w: WeatherWarning) => `${w.event}|${w.level}|${w.from}|${w.to}`;
|
||||
const distinct = warnings.filter((w, i) => warnings.findIndex((o) => key(o) === key(w)) === i);
|
||||
return (
|
||||
<>
|
||||
{distinct.map((w) => (
|
||||
<details key={key(w)} role="alert"
|
||||
className={`mb-3 rounded-xl border-2 px-4 py-2 ${LEVELS[w.level] ?? LEVELS[1]}`}>
|
||||
<summary className="flex cursor-pointer items-center gap-3">
|
||||
<Icon name="alert" className="size-5 shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<b>Ostrzeżenie IMGW, stopień {w.level}: {w.event}</b>
|
||||
<span className="block text-sm">{dateTime(w.from)} – {dateTime(w.to)}</span>
|
||||
</span>
|
||||
</summary>
|
||||
<p className="mt-2 text-sm">{w.text}</p>
|
||||
</details>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A thunderstorm or strong wind can cut the power, which is when the battery is the house's UPS. */
|
||||
export const threatensPower = (w: WeatherWarning) => /burz|silny wiatr/i.test(w.event);
|
||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user