Compare commits

..
37 Commits
Author SHA1 Message Date
root 655e84be8d Merge PR #91: fix(frontend): stale states, consistent setpoint names, contrast, 44 px theme switch
Release / Images (push) Successful in 3m18s
CI / Check (gateway, backend, frontend) (push) Successful in 1m30s
2026-10-24 18:06:10 +02:00
root 4cb8bfd27b Merge master into fix/settings-status-polish
CI / Check (gateway, backend, frontend) (push) Successful in 1m30s
# Conflicts:
#	frontend/src/ui.tsx
2026-10-24 18:02:41 +02:00
root bdea2a177d Merge PR #92: fix(frontend): readable energy diagram, battery view and inverter rows
CI / Check (gateway, backend, frontend) (push) Successful in 3m27s
2026-10-24 17:59:27 +02:00
root d8b06e2a89 Merge master into fix/energy-polish
CI / Check (gateway, backend, frontend) (push) Successful in 1m26s
# Conflicts:
#	frontend/src/BatterySettings.tsx
2026-10-24 17:53:29 +02:00
root 1405695da5 Merge PR #90: fix(gateway): cap the lease after a failed boost over a held change, battery hardening
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
2026-10-24 17:50:15 +02:00
root ea2862ae04 Merge PR #89: fix: weather across midnight and IMGW back-off, review findings
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
2026-10-24 17:47:52 +02:00
rootandClaude Opus 5.5 317950cb75 fix(ui): name setpoints consistently in Details after merging master
CI / Check (gateway, backend, frontend) (push) Successful in 1m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:46:46 +02:00
root 8423d723a1 Merge PR #88: fix(frontend): readable stats and history charts, 44 px range toggles
CI / Check (gateway, backend, frontend) (push) Successful in 1m27s
2026-10-24 17:45:30 +02:00
rootandClaude Opus 5.5 cb93f24365 fix(energy): readable flow diagram, calmer battery view, device status rows
CI / Check (gateway, backend, frontend) (push) Successful in 1m26s
Vertical flow diagram with 13+ px text, battery flow in the brand colour,
action-first IMGW hint, clear schedule table, status and values per device
and dimmed values for stale sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:45:18 +02:00
rootandClaude Opus 5.5 f181d0bbe3 fix(ui): stale status, clearer Ustawienia and history, contrast, 44 px theme switch
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
Stale devices and the side chip show a warning with an icon; setpoints are
named "Ciepła woda – oszczędnie" etc. in the UI and the audit labels; quick
actions get a one-line description; failed history rows get red or amber
chips; soft text and dark cards get more contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:44:50 +02:00
rootandClaude Opus 5.5 f57c3adc58 fix(http): share one failed-token throttle between endpoints
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:43:40 +02:00
rootandClaude Opus 5.5 56a457085e fix(battery): decide a raised reserve on the live table
The cached reading can be 30 minutes old. A reading missing fields now
counts as no usable settings instead of failing with a 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:43:40 +02:00
rootandClaude Opus 5.5 6dcbb5b8e4 fix(battery): ignore a reserve file outside 0 to 100
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:43:40 +02:00
rootandClaude Opus 5.5 ace189f5e2 fix(commands): cap a held lease when a later boost may have landed
A boost that failed after its write could leave a raised value under the
held change's far lease. The restore now keeps both values but runs
within one lease (ADR-012).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:43:40 +02:00
rootandClaude Opus 5.5 d356fb2a2c perf(stats): poll statistics every 5 minutes
CI / Check (gateway, backend, frontend) (push) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:42:49 +02:00
rootandClaude Opus 5.5 0fb139192d fix(frontend): hash public file contents into the cache version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:42:49 +02:00
rootandClaude Opus 5.5 40823683be fix(sw): fall back to the cached shell when a navigation stalls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:42:49 +02:00
rootandClaude Opus 5.5 1f43987db5 fix(battery): key schedule rows by slot, not by start time
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:42:49 +02:00
root e88c28899f Merge PR #87: feat(frontend): tidy the main screen, each value once
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
2026-10-24 17:42:32 +02:00
rootandClaude Opus 5.5 d5ab64aa61 fix(frontend): readable chart axes, tiles and range toggles
CI / Check (gateway, backend, frontend) (push) Successful in 1m30s
Statystyki: integer hour ticks with unit and axis labels, a legend, tiles without
a grid hole or a wrapping big number. Range toggles are 44 px, equal width and
filled when selected. Historia: 4 to 6 time ticks and value ticks that hug the data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:41:38 +02:00
rootandClaude Opus 5.5 01c5e1efe5 feat(ui): rearrange the overview around four key figures
CI / Check (gateway, backend, frontend) (push) Successful in 1m29s
Warnings first as one-line collapsible banners, then CWU, home, outdoor and
power once; schematic with device rows, weather as rows and one temperature
details card. The side panel icon follows today's forecast. Identical IMGW
warnings show once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:39:21 +02:00
rootandClaude Opus 5.5 0819ed8261 fix(weather): keep forecast across midnight and back off failing sources
The forecast expires at local midnight and covers four days; a failed
refresh serves the older value without retrying for 60 s, and IMGW has a
shorter timeout so it cannot hold the forecast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:39:16 +02:00
rootandClaude Opus 5.5 338befaffa fix(battery): return no schedule when a slot start is missing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:39:16 +02:00
root 378f4f6623 Merge PR #86: feat: weather, PV forecast and IMGW warnings
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
Release / Images (push) Successful in 3m1s
2026-10-24 17:15:27 +02:00
root 82670eb4a1 Merge remote-tracking branch 'origin/master' into feat/weather
CI / Check (gateway, backend, frontend) (push) Successful in 1m24s
# Conflicts:
#	backend/README.md
#	backend/src/main/java/home/backend/Backend.java
#	backend/src/main/java/home/backend/adapters/http/ApiServer.java
#	backend/src/main/java/home/backend/adapters/influx/InfluxHistoryRepository.java
#	backend/src/test/java/home/backend/adapters/http/ApiServerTest.java
#	frontend/README.md
2026-10-24 17:12:06 +02:00
root d07be384a1 Merge PR #85: feat: heat pump statistics in Historia
CI / Check (gateway, backend, frontend) (push) Successful in 1m24s
2026-10-24 17:09:59 +02:00
root 860269a353 Merge master into feat/heat-pump-stats
CI / Check (gateway, backend, frontend) (push) Successful in 1m26s
# Conflicts:
#	backend/src/main/java/home/backend/Backend.java
#	backend/src/main/java/home/backend/adapters/http/ApiServer.java
#	backend/src/test/java/home/backend/adapters/http/ApiServerTest.java
#	frontend/README.md
2026-10-24 17:07:30 +02:00
rootandClaude Opus 5.5 e4481eeeae chore(ci): rerun after a Maven download reset
CI / Check (gateway, backend, frontend) (push) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:05:01 +02:00
root e1e50eb7c2 Merge PR #84: feat: change history in Ustawienia
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
2026-10-24 17:03:31 +02:00
root c7d88e6c0b Merge PR #83: feat(frontend): installable PWA with an offline app shell 2026-10-24 17:03:31 +02:00
rootandClaude Opus 5.5 186c1984c0 feat: weather, PV estimate and IMGW warnings on Podgląd
CI / Check (gateway, backend, frontend) (push) Failing after 20s
Backend GET /api/weather: Open-Meteo forecast for three days, a PV
estimate calibrated on the last 14 measured days (Deye + Fronius daily
energy over forecast radiation), and IMGW warnings for Krosno (TERYT
1862, else 1807). Cached 30 and 10 minutes, stale answer on failure.
Frontend: Pogoda card, warning banners, and a hint on Bateria to keep
the battery full on a storm or strong-wind warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:00:40 +02:00
root 83d9e1ecfc Merge PR #82: chore(frontend): split the bundle, charts in their own chunk
CI / Check (gateway, backend, frontend) (push) Successful in 1m28s
2026-10-24 16:59:51 +02:00
rootandClaude Opus 5.5 cbf507fa18 feat: heat pump statistics per day in the Historia tab
CI / Check (gateway, backend, frontend) (push) Successful in 1m24s
GET /api/stats?range=7d|30d computes compressor, CWU, CO and heater hours,
compressor starts and temperatures per Europe/Warsaw day from the raw
heat_pump polls. Hours hold each poll for at most 600 s so gaps add nothing;
starts are 0 -> >0 transitions and are a lower bound at 300 s polling.
Historia gets a Wykresy | Statystyki switch (shared SubView helper, also
used by Energia) with totals tiles, a daily chart and a cycling hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:59:42 +02:00
rootandClaude Opus 5.5 a99f2b677a feat(frontend): show the change history in Ustawienia
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:59:26 +02:00
rootandClaude Opus 5.5 928915d059 feat(backend): serve the change history from the gateway's audit rows
GET /api/audit merges the heat pump and Deye audit measurements, newest
first, with Polish labels for the setpoints and Deye registers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:57:21 +02:00
rootandClaude Opus 5.5 c0c46973e8 chore(frontend): split the chart library and lazy-load screens
CI / Check (gateway, backend, frontend) (push) Successful in 1m29s
The single index chunk (672 kB, gzip 199 kB) triggered Vite's 500 kB
warning. Recharts goes into its own "charts" chunk; Historia, Ustawienia
and Stan load on demand behind Suspense ("Ładowanie…"). Energy stays in
the main chunk because the first screen imports EnergyToday from it.

Before: index 671.93 kB (gzip 199.08 kB).
After: index 264.23 kB (gzip 83.46 kB), charts 395.52 kB (gzip 113.20 kB),
Settings 9.82 kB, History 2.43 kB, Status 1.72 kB. No warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:55:48 +02:00
rootandClaude Opus 5.5 64e1e6ffec feat(frontend): installable PWA with an offline app shell
CI / Check (gateway, backend, frontend) (push) Successful in 1m26s
Manifest, icons (192, 512, maskable, apple-touch-icon) and iOS meta tags let the
phone add the app to the home screen. A service worker precaches index.html and
the built files, so the app opens offline and shows its own "offline" state; /api/
and any non-GET or cross-origin request always go to the network. Navigations are
network first so an update shows at once; the cache name carries a hash of the file
list and old caches are deleted on activate.

The worker is hand-written (src/sw.js, ~40 lines) with the file list injected by a
10-line Vite plugin, instead of vite-plugin-pwa: that pulls in Workbox and a large
dependency tree for what is a precache plus one fetch rule. The bottom nav already
pads for the iPhone home indicator (viewport-fit=cover, safe-area-inset-bottom).

nginx: manifest-src in the CSP and application/manifest+json for the manifest
(mime.types lacks it). sw.js and index.html were already no-cache, hashed assets
immutable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:55:47 +02:00
105 changed files with 4360 additions and 378 deletions

No files matched your search

+21
View File
@@ -8,6 +8,11 @@ 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`):
@@ -22,6 +27,20 @@ Same Origin, header and JSON rules as the other writes; `Remote-User` goes on 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`.
@@ -34,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,25 +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() {
}
@@ -33,7 +47,8 @@ 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 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(),
@@ -42,7 +57,17 @@ final class Backend {
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
new BatteryService(gateway, commands, clock));
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). */
@@ -9,14 +9,17 @@ 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;
@@ -87,7 +90,8 @@ public class ApiServer {
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
BatteryService battery) throws IOException {
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).
@@ -105,8 +109,11 @@ public class ApiServer {
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)))));
@@ -337,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
+ "&current=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);
}
}
@@ -86,8 +86,13 @@ public class BatteryService {
: "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. */
/** 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";
@@ -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,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;
}
@@ -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,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,6 +2,8 @@ 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;
@@ -24,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;
@@ -44,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;
@@ -63,6 +74,8 @@ 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;
@@ -95,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;
@@ -178,7 +201,10 @@ class ApiServerTest {
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
new BatteryService(gateway, batteryCommands, clock));
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();
}
@@ -187,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,
@@ -400,6 +447,37 @@ class ApiServerTest {
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");
@@ -421,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"));
}
}
@@ -279,6 +279,14 @@ class ServicesTest {
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));
@@ -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));
}
}
+12
View File
@@ -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" }
]
}
+92
View File
@@ -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]}}
+5 -2
View File
@@ -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.
@@ -95,11 +96,13 @@ Functional:
- 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
View File
@@ -20,6 +20,22 @@ 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
View File
@@ -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>
+6 -1
View File
@@ -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

+15
View File
@@ -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" }
]
}
+24 -16
View File
@@ -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,7 +20,7 @@ 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 => {
@@ -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>
+3 -3
View File
@@ -24,7 +24,7 @@ describe("Battery view", () => {
await show({ powerW: 3 });
expect(screen.getByRole("img", { name: "Naładowanie baterii: 99 %" })).toBeInTheDocument();
expect(screen.getByText("14,2 kWh z 14,3 kWh")).toBeInTheDocument();
expect(screen.getByText("14,2 / 14,3 kWh")).toBeInTheDocument();
expect(screen.getByText("stoi")).toBeInTheDocument();
});
@@ -47,7 +47,7 @@ describe("Battery view", () => {
expect(screen.getByText("Rezerwa na awarię: bateria nie rozładowuje się na co dzień")).toBeInTheDocument();
const table = screen.getByRole("table");
expect(screen.getByText("wyłączony")).toBeInTheDocument();
expect(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");
@@ -101,7 +101,7 @@ describe("Battery view", () => {
total: { chargeKwh: null, dischargeKwh: null } });
expect(screen.getByText(/Dane są nieaktualne/)).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Naładowanie baterii: —" })).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Naładowanie baterii: —" }).closest(".opacity-50")).not.toBeNull();
expect(screen.getByText("Dziś naładowano").parentElement).toHaveTextContent("—");
});
});
+19 -8
View File
@@ -1,10 +1,11 @@
import { useState } from "react";
import { flowing } from "./flow";
import { amps, DASH, hour, kwh, number, percent, power } from "./format";
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, Loading, Row } from "./ui";
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) {
@@ -30,21 +31,21 @@ function Gauge({ soc, stored, capacity }: { soc: number | null; stored: number |
<div className="absolute inset-0 grid place-items-center text-center">
<div>
<p className="text-4xl font-bold tabular-nums">{percent(soc)}</p>
{stored != null && capacity != null && <p className="text-sm text-soft">{kwh(stored)} z {kwh(capacity)}</p>}
{stored != null && capacity != null && <p className="text-sm text-soft">{number(stored, 1)} / {kwh(capacity)}</p>}
</div>
</div>
</div>
);
}
function Now({ b }: { b: BatteryData }) {
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">
<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-run" : ""}`}>
<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>
@@ -89,13 +90,23 @@ function Totals({ b }: { b: BatteryData }) {
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} />
<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} /></div>
<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>
+11 -1
View File
@@ -1,6 +1,6 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { answer, example, serve } from "./fixtures";
import type { Battery as BatteryData } from "./types";
@@ -191,6 +191,16 @@ describe("Battery schedule editing", () => {
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();
+11 -10
View File
@@ -3,7 +3,7 @@ import { postJson, WriteError } from "./api";
import { percent } from "./format";
import { reload } from "./poll";
import type { Battery, BatteryChange, BatteryResult, BatterySlot } from "./types";
import { Button, Card, Pill, Row, Stepper } from "./ui";
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.
@@ -109,7 +109,7 @@ export function Reserve({ b, onDone }: { b: Battery; onDone: (notice: Notice) =>
<>
<Stepper label="rezerwa na awarię" value={draft ?? reserve.pct} min={reserve.floorPct} max={100}
format={percent} disabled={busy} onStep={step} />
<p className="text-sm text-soft">od {percent(reserve.floorPct)} do {percent(100)}</p>
<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">
@@ -275,19 +275,20 @@ export function Schedule({ b, onDone }: { b: Battery; onDone: (notice: Notice) =
}
const edit = b.editable && b.reserve ? <Button onClick={() => setEditing(true)}>Edytuj</Button> : null;
return (
<Card title="Harmonogram" aside={<div className="flex items-center gap-2">{off && <Pill tone="off">wyłączony</Pill>}{edit}</div>}>
<table className={`w-full text-[0.95rem] ${off ? "opacity-50" : ""}`}>
<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-sm text-soft">
<th scope="col" className="pb-2 font-medium">Godziny</th>
<th scope="col" className="pb-2 font-medium">Poziom</th>
<th scope="col" className="pb-2 text-right font-medium">Ładowanie z sieci</th>
<tr 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) => (
<tr key={slot.from} className="border-t border-edge">
{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>
+69
View File
@@ -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();
});
});
+64
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+11 -6
View File
@@ -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>
+34 -1
View File
@@ -1,6 +1,6 @@
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { example, serve } from "./fixtures";
import type { Energy as EnergyData, EnergyDetails } from "./types";
@@ -15,6 +15,11 @@ 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 });
@@ -90,6 +95,34 @@ 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";
+75 -73
View File
@@ -1,13 +1,14 @@
import { useCallback, useEffect, useState, type KeyboardEvent } 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 INVERTERS = { deye: "Falownik Deye", fronius: "Falownik Fronius" } as const;
@@ -16,7 +17,7 @@ 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" }];
});
}
@@ -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 }) {
@@ -59,15 +60,15 @@ function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof
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} aria-hidden="true">
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 5 : 3} strokeLinecap="round" opacity={on ? 0.35 : 0.6} />
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={5} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
<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>
);
}
// With onOpen the node is a button: the chevron says it leads somewhere.
function Node({ id, value, note, onOpen }: { id: keyof typeof NODES; value: string; note?: string; onOpen?: () => void }) {
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",
@@ -80,12 +81,14 @@ function Node({ id, value, note, onOpen }: { id: keyof typeof NODES; value: stri
};
return (
<g aria-hidden={onOpen ? undefined : true} {...button}>
<rect x={x - 78} y={y - 40} width={156} height={80} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={1.5} />
<g transform={`translate(${x - 64} ${y - 26})`} color={colour}><Icon name={icon} size={28} /></g>
<text x={x - 28} y={y - 10} fontSize={14} className="scene-soft">{label}</text>
<text x={x - 28} y={y + 16} fontSize={22} fontWeight={700} className="scene-text">{value}</text>
{note && <text x={x - 28} y={y + 33} fontSize={12} className="scene-soft">{note}</text>}
{onOpen && <text x={x + 62} y={y + 8} fontSize={28} textAnchor="middle" className="scene-soft" aria-hidden="true">›</text>}
<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>
);
}
@@ -102,25 +105,33 @@ export function flowSummary(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">
<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" onOpen={onBattery} 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;
@@ -130,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">
@@ -159,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>
@@ -174,8 +191,26 @@ 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);
@@ -183,7 +218,7 @@ function Devices() {
<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} />}
@@ -195,6 +230,7 @@ function Now({ onBattery }: { onBattery: () => void }) {
const { data: e, offline } = usePoll<EnergyData>("api/energy");
if (!e) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
const missing = silentInverters(e);
const quiet = quietNodes(e);
return (
<>
<Freshness offline={offline} time={e.time} />
@@ -203,10 +239,10 @@ function Now({ onBattery }: { onBattery: () => void }) {
<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>
</div>
</>
@@ -214,52 +250,18 @@ function Now({ onBattery }: { onBattery: () => void }) {
}
const VIEWS = { now: "Teraz", battery: "Bateria", inverters: "Falowniki" } as const;
type ViewId = keyof typeof VIEWS;
// #energy is "Teraz", #energy/battery and #energy/inverters the others: every view is a history entry, so Back returns.
function viewFromHash(): ViewId {
const [screen, view = ""] = location.hash.slice(1).split("/");
return screen === "energy" && view in VIEWS ? (view as ViewId) : "now";
}
function useView() {
const [view, setView] = useState(viewFromHash);
useEffect(() => {
const onHash = () => setView(viewFromHash());
addEventListener("hashchange", onHash);
return () => removeEventListener("hashchange", onHash);
}, []);
const show = (id: ViewId) => {
location.hash = id === "now" ? "energy" : `energy/${id}`;
};
return [view, show] as const;
}
function ViewSwitch({ view, onSelect }: { view: ViewId; onSelect: (id: ViewId) => void }) {
return (
<div role="tablist" aria-label="Widok energii" className="mb-5 flex gap-1 rounded-xl border border-edge bg-panel p-1 lg:max-w-md">
{(Object.entries(VIEWS) as [ViewId, string][]).map(([id, label]) => (
<button key={id} role="tab" id={`energy-tab-${id}`} aria-selected={id === view} aria-controls="energy-view"
onClick={() => onSelect(id)}
className="min-h-11 flex-1 rounded-lg px-3 font-semibold text-soft focus-visible:outline-2 focus-visible:outline-brand aria-selected:bg-brand aria-selected:text-white dark:aria-selected:text-black">
{label}
</button>
))}
</div>
);
}
export function Energy() {
const [view, show] = useView();
const [view, show] = useSubView("energy", VIEWS);
return (
<>
<PageTitle title="Energia" subtitle="Falowniki Deye i Fronius, licznik domu" />
<ViewSwitch view={view} onSelect={show} />
<div role="tabpanel" id="energy-view" aria-labelledby={`energy-tab-${view}`}>
<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 />}
</div>
</SubViewPanel>
</>
);
}
+97
View File
@@ -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
View File
@@ -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>
</>
);
}
+49 -12
View File
@@ -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
View File
@@ -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>
)}
</>
+28 -19
View File
@@ -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 () => {
+20 -7
View File
@@ -1,5 +1,6 @@
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";
@@ -7,10 +8,10 @@ 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." },
@@ -143,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}`
@@ -283,15 +289,21 @@ 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);
@@ -330,6 +342,7 @@ export function Settings() {
)}
</Card>
</div>
<ChangeHistory />
</>
);
}
+133
View File
@@ -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>
);
}
+23
View File
@@ -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();
});
});
+8 -3
View File
@@ -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>
+59
View File
@@ -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>
);
}
+32
View File
@@ -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]);
});
});
+25 -9
View File
@@ -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]}
+138
View File
@@ -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();
});
});
+112
View File
@@ -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);
+3 -3
View File
@@ -9,7 +9,7 @@
--color-page: #f2f6f3;
--color-panel: #ffffff;
--color-ink: #13201a;
--color-soft: #5b6b63;
--color-soft: #4f5f57;
--color-edge: #e2e9e5;
--color-brand: #0f8a5f;
--color-brand-soft: #e3f4ec;
@@ -29,10 +29,10 @@
:root[data-theme="dark"] {
color-scheme: dark;
--color-page: #0b1310;
--color-panel: #142019;
--color-panel: #1a2a21;
--color-ink: #eef5f1;
--color-soft: #a9bab1;
--color-edge: #24352c;
--color-edge: #35493d;
--color-brand: #3fd197;
--color-brand-soft: #143628;
--color-run: #ff7a8a;
+5
View File
@@ -7,3 +7,8 @@ import { applyTheme, storedTheme } from "./theme";
applyTheme(storedTheme()); // before the first paint, so a dark choice does not flash white
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
// Offline shell (public/manifest.webmanifest, src/sw.js); not in dev, where it would serve stale modules.
if (import.meta.env.PROD && "serviceWorker" in navigator) {
navigator.serviceWorker.register("./sw.js").catch(() => undefined); // the app works without it
}
+46
View File
@@ -0,0 +1,46 @@
// The service worker, a classic script. vite.config.ts fills in the two placeholders below at build.
const SHELL = __SHELL__;
const CACHE = `shell-${__VERSION__}`;
// Only the app's own files are cached; /api/ must always reach the network, the UI shows "offline" itself.
function isShellRequest(request) {
const url = new URL(request.url);
return request.method === "GET" && url.origin === self.location.origin && !url.pathname.includes("/api/");
}
const NAVIGATION_TIMEOUT_MS = 4000;
// A stalled connection must not hang the app: after the timeout the cached shell opens instead.
function fetchWithin(request, ms) {
let timer;
const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("timeout")), ms); });
return Promise.race([fetch(request), timeout]).finally(() => clearTimeout(timer));
}
async function answer(request) {
const cache = await caches.open(CACHE);
if (request.mode === "navigate") {
try {
return await fetchWithin(request, NAVIGATION_TIMEOUT_MS); // network first, so an update shows at once
} catch {
return cache.match("./index.html");
}
}
return (await cache.match(request)) ?? fetch(request);
}
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((names) => Promise.all(names.filter((name) => name !== CACHE).map((name) => caches.delete(name))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
if (isShellRequest(event.request)) event.respondWith(answer(event.request));
});
+95
View File
@@ -0,0 +1,95 @@
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type Listener = (event: unknown) => void;
const listeners: Record<string, Listener> = {};
const cacheStore = new Map<string, Response>();
const cache = {
addAll: vi.fn(async () => undefined),
match: vi.fn(async (request: string | { url: string }) => cacheStore.get(typeof request === "string" ? request : request.url)),
};
const caches = {
open: vi.fn(async () => cache),
keys: vi.fn(async () => ["shell-old", "shell-test"]),
delete: vi.fn(async () => true),
};
const self = {
location: { origin: "https://pompa.test" },
addEventListener: (type: string, listener: Listener) => { listeners[type] = listener; },
skipWaiting: vi.fn(),
clients: { claim: vi.fn(async () => undefined) },
};
// sw.js is a classic script with two placeholders filled in at build; run it with a fake worker scope.
const source = readFileSync("src/sw.js", "utf8")
.replace("__SHELL__", '["./", "./index.html"]')
.replace("__VERSION__", '"test"');
new Function("self", "caches", "fetch", source)(self, caches, (request: Request) => fetch(request));
function fetchEvent(url: string, init: { method?: string; mode?: string } = {}) {
const request = { url, method: init.method ?? "GET", mode: init.mode ?? "same-origin" };
const respondWith = vi.fn();
listeners.fetch({ request, respondWith });
return respondWith;
}
async function run(type: "install" | "activate") {
let done: Promise<unknown> = Promise.resolve();
listeners[type]({ waitUntil: (promise: Promise<unknown>) => { done = promise; } });
await done;
}
beforeEach(() => vi.clearAllMocks());
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
describe("service worker", () => {
it("leaves /api/ to the network", () => {
expect(fetchEvent("https://pompa.test/api/heat-pump")).not.toHaveBeenCalled();
expect(fetchEvent("https://pompa.test/sub/api/energy")).not.toHaveBeenCalled();
});
it("leaves writes and other origins alone", () => {
expect(fetchEvent("https://pompa.test/assets/a.js", { method: "POST" })).not.toHaveBeenCalled();
expect(fetchEvent("https://elsewhere.test/assets/a.js")).not.toHaveBeenCalled();
});
it("serves the app's own files from the cache, else from the network", async () => {
const cached = new Response("cached");
cacheStore.set("https://pompa.test/assets/a.js", cached);
const fresh = new Response("fresh");
vi.stubGlobal("fetch", vi.fn(async () => fresh));
expect(await fetchEvent("https://pompa.test/assets/a.js").mock.calls[0][0]).toBe(cached);
expect(await fetchEvent("https://pompa.test/assets/b.js").mock.calls[0][0]).toBe(fresh);
});
it("opens the cached index.html when a navigation fails offline", async () => {
const index = new Response("shell");
cacheStore.set("./index.html", index);
vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("offline"); }));
const respondWith = fetchEvent("https://pompa.test/", { mode: "navigate" });
expect(await respondWith.mock.calls[0][0]).toBe(index);
});
it("opens the cached index.html when a navigation stalls", async () => {
vi.useFakeTimers();
const index = new Response("shell");
cacheStore.set("./index.html", index);
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => undefined)));
const respondWith = fetchEvent("https://pompa.test/", { mode: "navigate" });
const answer = respondWith.mock.calls[0][0];
await vi.advanceTimersByTimeAsync(4000);
expect(await answer).toBe(index);
vi.useRealTimers();
});
it("precaches the shell on install", async () => {
await run("install");
expect(cache.addAll).toHaveBeenCalledWith(["./", "./index.html"]);
});
it("deletes other versions on activate", async () => {
await run("activate");
expect(caches.delete).toHaveBeenCalledTimes(1);
expect(caches.delete).toHaveBeenCalledWith("shell-old");
});
});
+75
View File
@@ -131,6 +131,21 @@ export interface History {
points: Point[];
}
export interface AuditEntry {
time: string;
source: "heat_pump" | "battery";
what: string;
oldValue: number | null;
newValue: number | null;
unit: string; // "°C", "%", "HHMM" (1830) or "0/1"
who: string;
status: string;
}
export interface Audit {
entries: AuditEntry[];
}
export interface Health {
time: string;
gatewayReachable: boolean;
@@ -139,6 +154,37 @@ export interface Health {
devices: { name: string; up: boolean; lastSuccess: string | null; lastError: string | null }[];
}
export interface WeatherDay {
date: string;
weatherCode: number | null; // WMO code
description: string;
tMax: number | null;
tMin: number | null;
precipitationMm: number | null;
sunshineHours: number | null;
pvForecastKwh: number | null; // null until the backend has enough measured days to calibrate
}
/** An IMGW warning covering the house, in force or announced; level 1 to 3. */
export interface WeatherWarning {
event: string;
level: number;
from: string;
to: string;
text: string;
}
export interface Weather {
time: string;
today: WeatherDay;
tomorrow: WeatherDay;
dayAfter: WeatherDay;
now: { temperature: number | null };
warnings: WeatherWarning[];
pvFactor: number | null;
stale: boolean;
}
export interface Setpoint {
name: string;
register: number;
@@ -192,3 +238,32 @@ export interface CommandResult {
restoreAt?: string | null;
results?: { status: LineStatus; message: string; restoreAt: string | null }[];
}
export interface StatsDay {
date: string; // local day, YYYY-MM-DD
compressorHours: number | null;
compressorStarts: number | null;
cwuHours: number | null;
coHours: number | null;
heaterHours: number | null;
avgOutdoorTemp: number | null;
minCwuTemp: number | null;
maxCwuTemp: number | null;
}
export interface StatsTotals {
compressorHours: number;
compressorStarts: number;
cwuHours: number;
coHours: number;
heaterHours: number;
avgOutdoorTemp: number | null;
minCwuTemp: number | null;
maxCwuTemp: number | null;
}
export interface Stats {
range: "7d" | "30d";
days: StatsDay[];
totals: StatsTotals;
}
+13 -7
View File
@@ -36,7 +36,7 @@ const DOT: Record<Tone, string> = {
export function Pill({ tone, live = false, children }: { tone: Tone; live?: boolean; children: ReactNode }) {
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${PILL[tone]}`}>
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-semibold ${PILL[tone]}`}>
<Dot tone={tone} live={live} />
{children}
</span>
@@ -58,17 +58,24 @@ const BANNER = {
ok: "border-brand bg-brand-soft text-brand",
};
export function Banner({ tone, children }: { tone: keyof typeof BANNER; children: ReactNode }) {
/** Compact is for a note above a screen that must still show its content on the first page of a phone. */
export function Banner({ tone, compact = false, children }: { tone: keyof typeof BANNER; compact?: boolean; children: ReactNode }) {
return (
<div role={tone === "ok" ? "status" : "alert"}
className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${BANNER[tone]}`}>
className={`rounded-xl border-2 font-semibold ${compact ? "mb-3 px-3 py-2 text-sm" : "mb-4 px-4 py-3"} ${BANNER[tone]}`}>
{children}
</div>
);
}
/** The backend knows best when a source has gone quiet; the age check covers a slow gateway. */
export const isStale = (time: string | null | undefined, stale = false): boolean =>
!!time && (stale || ageMinutes(time) >= STALE_MINUTES);
/** Offline or old data, in words; nothing when the data is fresh. */
export function Freshness({ offline, time, stale = false }: { offline: boolean; time?: string | null; stale?: boolean }) {
export function Freshness({ offline, time, stale = false, compact = false }: {
offline: boolean; time?: string | null; stale?: boolean; compact?: boolean;
}) {
if (offline) {
return (
<Banner tone="bad">
@@ -77,9 +84,8 @@ export function Freshness({ offline, time, stale = false }: { offline: boolean;
</Banner>
);
}
// The backend knows best when a source has gone quiet; the age check covers a slow gateway.
if (time && (stale || ageMinutes(time) >= STALE_MINUTES)) {
return <Banner tone="warn">Dane są nieaktualne: {ago(time)}.</Banner>;
if (isStale(time, stale)) {
return <Banner tone="warn" compact={compact}>Dane są nieaktualne: {ago(time ?? null)}.</Banner>;
}
return null;
}
+31 -2
View File
@@ -1,14 +1,43 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { createHash } from "node:crypto";
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import type { Plugin } from "vite";
// Writes dist/sw.js from src/sw.js with the built files to precache; the cache name carries a hash of
// that list and of the public files' contents, so a new build gets a new cache and the old one is
// removed on activate.
function precachePlugin(): Plugin {
return {
name: "precache",
apply: "build",
writeBundle(options, bundle) {
const files = [...Object.keys(bundle), ...readdirSync("public")].sort();
const shell = ["./", ...files.map((file) => `./${file}`)];
const hash = createHash("sha256").update(shell.join());
readdirSync("public").sort().forEach((file) => hash.update(readFileSync(`public/${file}`)));
const version = hash.digest("hex").slice(0, 12);
const source = readFileSync("src/sw.js", "utf8")
.replace("__SHELL__", JSON.stringify(shell))
.replace("__VERSION__", JSON.stringify(version));
writeFileSync(`${options.dir}/sw.js`, source);
},
};
}
// The build in dist/ is what the frontend image's nginx serves (Dockerfile).
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [react(), tailwindcss(), precachePlugin()],
base: "./",
// The release tag from the Dockerfile's VERSION build arg; "dev" for a local build.
define: { __APP_VERSION__: JSON.stringify(process.env.VERSION ?? "dev") },
build: { outDir: "dist", emptyOutDir: true },
build: {
outDir: "dist",
emptyOutDir: true,
// The chart library is the bulk of the code and changes rarely: its own chunk, cached apart from the app.
rolldownOptions: { output: { codeSplitting: { groups: [{ name: "charts", test: /node_modules[\\/](recharts|d3-|victory-vendor|es-toolkit)/ }] } } },
},
// `npm run dev` asks a running backend for /api: BACKEND, by default the backend's own default port.
server: { proxy: { "/api": process.env.BACKEND ?? "http://localhost:8080" } },
test: { environment: "jsdom", setupFiles: ["src/test-setup.ts"] },
+5 -2
View File
@@ -69,7 +69,7 @@ reused; `commands.enabled` may stay false). `deyeCommands`: `reserveFile` (`rese
restore file, `{"pct": 50}`, replaced atomically; missing means 50, an unreadable file is logged and means 50),
`reserveFloorPct` (30; the reserve is never lower, nor below the inverter's shutdown SOC + 10),
`minIntervalSeconds` (60, per register) and `readBackDelayMillis` (1000). Same Bearer token and failed-login
throttle as `/commands`; `X-Requested-By` names the requester. While it is off, `/battery` answers 404.
throttle as `/commands` (one failed-token counter for both); `X-Requested-By` names the requester. While it is off, `/battery` answers 404.
- `GET /battery/reserve` answers `{"pct", "floorPct"}`; `POST /battery/reserve` `{"pct": 60}` changes it.
- `POST /battery/schedule` `{"enabled": true, "slots": [{"slot": 2, "start": 930, "soc": 90, "gridCharge": true}]}`;
@@ -79,7 +79,7 @@ throttle as `/commands`; `X-Requested-By` names the requester. While it is off,
register, 502 failed: the body's `read` holds the registers as read back, nothing is retried.
- Rules: SOC from the reserve to 100; grid charge written as 0 or 1 only; starts are HHMM, strictly increasing
from slot 1 to 6; an enabled schedule needs every slot SOC at or above the reserve; the reserve cannot be
raised above a slot SOC of an enabled schedule. The whole table is checked before anything is written,
raised above a slot SOC of an enabled schedule (decided on the table read live, 502 if that read fails). The whole table is checked before anything is written,
neighbouring registers go in one FC16, the Time of Use switch last, the days (bits 1 to 7 of 146) stay as read.
- Audit: `electricity.deye_settings_audit` (tags `register`, `requested_by`, `status`; fields `old_value`,
`new_value`). After a write the settings are polled again 3 s later. No restore and no lease: these are
@@ -102,6 +102,9 @@ and runs at its end. The gateway accepts it only if the end is in the future and
new value is lower than both the current value and the original that will come back; otherwise 422 and no
write. A renewal only ever extends a lease, so it never shortens a held change. A held change may replace a
pending boost (the original stays the first one); a later ordinary change on a held one is a lease again.
If that ordinary change fails after its write may have landed (lost reply, read-back mismatch), the held restore
keeps the original and accepts both values, but its lease is capped to now + 15 minutes: nothing above the
original outlives the boost, and without the backend the restore runs within 15 minutes.
**Restore now.** `POST /commands/restore` with `{"setpoint": "cwu_economy"}` (same token and throttle; the
requester comes from `X-Requested-By`) runs that setpoint's pending restore at once through the usual path.
@@ -9,7 +9,6 @@ import home.gateway.domain.ScheduleChange;
import home.gateway.domain.SlotChange;
import java.io.IOException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
@@ -30,9 +29,9 @@ class BatteryEndpoint implements HttpHandler {
private final BatteryCommands battery;
private final TokenGuard guard;
BatteryEndpoint(BatteryCommands battery, String token, Clock clock) {
BatteryEndpoint(BatteryCommands battery, String token, FailedLoginThrottle throttle) {
this.battery = battery;
this.guard = new TokenGuard(token, clock);
this.guard = new TokenGuard(token, throttle);
}
@Override
@@ -14,7 +14,6 @@ import home.gateway.domain.Setpoint;
import home.gateway.domain.VerifiedValue;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
@@ -55,8 +54,8 @@ class CommandsEndpoint implements HttpHandler {
private final Snapshot snapshot;
private final TokenGuard guard;
CommandsEndpoint(Commands commands, Snapshot snapshot, String token, Clock clock) {
this.guard = new TokenGuard(token, clock);
CommandsEndpoint(Commands commands, Snapshot snapshot, String token, FailedLoginThrottle throttle) {
this.guard = new TokenGuard(token, throttle);
this.commands = commands;
this.snapshot = snapshot;
}
@@ -50,11 +50,13 @@ public class GatewayHttpServer implements AutoCloseable {
private final Clock clock;
private final Set<String> criticalDevices;
private final HttpServer server;
private final FailedLoginThrottle throttle;
public GatewayHttpServer(Snapshot snapshot, Clock clock, InetSocketAddress address,
Set<String> criticalDevices) throws IOException {
this.snapshot = snapshot;
this.clock = clock;
this.throttle = new FailedLoginThrottle(clock);
this.criticalDevices = criticalDevices;
this.server = HttpServer.create(address, 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
@@ -66,12 +68,12 @@ public class GatewayHttpServer implements AutoCloseable {
* read-only server needs no token; call before {@link #start()}.
*/
public void serveCommands(Commands commands, String token) {
server.createContext("/commands", new CommandsEndpoint(commands, snapshot, token, clock));
server.createContext("/commands", new CommandsEndpoint(commands, snapshot, token, throttle));
}
/** Adds the battery settings endpoints (ADR-013) with their own token check; call before {@link #start()}. */
public void serveBattery(BatteryCommands battery, String token) {
server.createContext("/battery", new BatteryEndpoint(battery, token, clock));
server.createContext("/battery", new BatteryEndpoint(battery, token, throttle));
}
public void start() {
@@ -5,11 +5,10 @@ import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/** The shared token of the write endpoints (S6) and the throttle on wrong ones. */
/** The shared token of the write endpoints (S6); wrong ones count on the throttle all endpoints share. */
final class TokenGuard {
static final int MIN_TOKEN_LENGTH = 16;
@@ -18,13 +17,13 @@ final class TokenGuard {
private final byte[] token;
private final FailedLoginThrottle throttle;
TokenGuard(String token, Clock clock) {
TokenGuard(String token, FailedLoginThrottle throttle) {
if (token == null || token.length() < MIN_TOKEN_LENGTH) {
throw new IllegalArgumentException(
"command token must be at least " + MIN_TOKEN_LENGTH + " characters");
}
this.token = token.getBytes(StandardCharsets.UTF_8);
this.throttle = new FailedLoginThrottle(clock);
this.throttle = throttle;
}
/** True if the request may go on; otherwise it has been answered with 429 or 401. */
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.gateway.application.ReserveStore;
import home.gateway.domain.Schedule;
import java.io.IOException;
import java.nio.file.Files;
@@ -24,13 +25,17 @@ public class JsonReserveStore implements ReserveStore {
this.file = file;
}
/** @throws IOException when the file exists but is not a valid reserve */
/** @throws IOException when the file exists but is not a valid reserve (0 to 100 %) */
@Override
public OptionalInt load() throws IOException {
if (!Files.exists(file)) {
return OptionalInt.empty();
}
return OptionalInt.of(json.readValue(file.toFile(), Stored.class).pct());
int pct = json.readValue(file.toFile(), Stored.class).pct();
if (pct < 0 || pct > Schedule.MAX_SOC) {
throw new IOException("reserve " + pct + " % is outside 0 to " + Schedule.MAX_SOC);
}
return OptionalInt.of(pct);
}
@Override
@@ -113,12 +113,16 @@ public class BatterySettings implements BatteryCommands {
"the reserve must be " + current.floorPct() + " to " + Schedule.MAX_SOC + " %");
}
if (pct > current.pct()) {
// Raising it must not break an enabled schedule; lowering never can.
var settings = freshSettings();
if (settings == null) {
return BatteryResult.of(Status.STALE, staleMessage());
// Raising it must not break an enabled schedule; lowering never can. The cached reading may
// predate a schedule change (ours or the Solarman app's), so the table is read live.
Schedule schedule;
try {
schedule = ProgramRegisters.decode(registers.read(ProgramRegisters.FIRST, ProgramRegisters.COUNT));
} catch (IOException e) {
return BatteryResult.of(Status.FAILED, "cannot read the settings before raising the reserve: "
+ e.getMessage());
}
if (settings.schedule().enabled() && lowestSoc(settings.schedule()) < pct) {
if (schedule.enabled() && lowestSoc(schedule) < pct) {
return BatteryResult.of(Status.REJECTED, "the schedule is enabled and a slot SOC is below "
+ pct + " %: raise the slot SOCs first or disable the schedule");
}
@@ -259,8 +263,24 @@ public class BatterySettings implements BatteryCommands {
.findFirst().map(BatterySettings::fromReading).orElse(null);
}
private static final List<String> REQUIRED_FIELDS = requiredFields();
private static List<String> requiredFields() {
var names = new ArrayList<>(List.of("tou_enabled", "tou_days", "shutdown_soc_pct"));
for (int slot = 1; slot <= Schedule.SLOTS; slot++) {
for (var suffix : List.of("start_hhmm", "soc_pct", "grid_charge")) {
names.add("prog" + slot + "_" + suffix);
}
}
return names;
}
/** Null when the reading lacks a field: that is no usable settings, not an error. */
private static Settings fromReading(Reading reading) {
var fields = reading.fields();
if (!REQUIRED_FIELDS.stream().allMatch(fields::containsKey)) {
return null;
}
var slots = new ArrayList<Slot>();
for (int slot = 1; slot <= Schedule.SLOTS; slot++) {
String prefix = "prog" + slot + "_";
@@ -171,12 +171,18 @@ public class CommandService implements Commands {
/**
* The first change's restore must survive a failed second one. The failed write may still have
* landed (a lost reply), so the register can hold either value and both count as ours.
* landed (a lost reply), so the register can hold either value and both count as ours. Unless
* the failed command was itself held, the restore is an ordinary lease from now on, even if it
* was held: a value that may have landed must not outlive the boost (ADR-012).
*/
private CommandResult failedWrite(Command command, Setpoint setpoint, Integer old, Restore previous,
String message) {
if (previous != null) {
restores.put(previous.register(), previous.alsoApplied(command.value()));
var restore = previous.alsoApplied(command.value());
if (command.heldUntil() == null) {
restore = restore.leaseCappedAt(clock.instant().plus(Restore.LEASE));
}
restores.put(previous.register(), restore);
persist();
}
return failed(command, setpoint, old, message);
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more