Compare commits

..
42 Commits
Author SHA1 Message Date
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
root b288c98106 Merge PR #81: feat: battery schedule and reserve from the app (ADR-013)
CI / Check (gateway, backend, frontend) (push) Successful in 1m27s
Release / Images (push) Successful in 3m2s
2026-10-24 15:55:04 +02:00
rootandClaude Opus 5.5 e5e13cedb5 docs: ADR-013 battery settings from the app, spec F8, register notes
CI / Check (gateway, backend, frontend) (push) Successful in 1m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:51:47 +02:00
rootandClaude Opus 5.5 a594512f33 feat(frontend): edit the battery reserve and schedule in the Bateria view
Reserve stepper with the API's bounds, schedule edit mode with a
confirmation summary and Polish results; hidden when not editable.
Stepper and Button move to ui.tsx for reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:31:13 +02:00
rootandClaude Opus 5.5 a5ba9e8756 feat(backend): change the battery schedule and reserve through the gateway
POST /api/battery/schedule and /api/battery/reserve with the same
guards as the other writes; /api/battery shows the reserve and whether
changes are possible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:26:58 +02:00
rootandClaude Opus 5.5 619c3ee990 feat(gateway): change the Deye battery schedule and reserve (off by default)
Token-protected /battery/schedule and /battery/reserve: allowlisted Time
of Use changes checked as a whole against a fresh reading and the live
registers, FC16 per range, read-back, audit, rate limit per register.
The reserve is an app setting kept in reserve.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:23:33 +02:00
rootandClaude Opus 5.5 8111405326 feat(gateway): write multiple holding registers (FC16)
CI / Check (gateway, backend, frontend) (push) Successful in 1m21s
Needed for the Deye's program table; works over Solarman V5 unchanged,
as the RTU frame wraps any PDU. The echo of address and count is checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:03:29 +02:00
root 965dfae644 Merge PR #80: feat: Bateria and Falowniki views in the Energia tab
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
2026-10-24 14:57:52 +02:00
rootandClaude Opus 5.5 2da7246fe4 feat(frontend): Bateria and Falowniki views in the Energia tab
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
A segmented switch (Teraz, Bateria, Falowniki) kept in the URL hash so
Back returns to the previous view; tapping the battery in the flow
diagram opens Bateria. The device list moves to Falowniki.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:55:06 +02:00
rootandClaude Opus 5.5 a4dc88cbbc feat(backend): serve the Deye battery state and settings at /api/battery
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:51:44 +02:00
rootandClaude Opus 5.5 a01c507a6e feat(gateway): read the Deye battery settings into electricity.deye_settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:49:38 +02:00
root a8ef638fcd Merge PR #79: feat: red accent for running parts and a Salon sensor card
CI / Check (gateway, backend, frontend) (push) Successful in 1m22s
Release / Images (push) Successful in 2m53s
2026-10-24 14:10:27 +02:00
root 8396372943 Merge PR #78: feat(frontend): show the direction of energy flows with arrows
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
2026-10-24 14:07:59 +02:00
rootandClaude Opus 5.5 25f76ea084 feat(frontend): red accent for running parts and a Salon sensor card
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
A running pump, pump chip and headline are red instead of green; stopped
stays grey and the alarm is now solid so the two cannot be confused. The
BME280 reading shows in a Salon card, with brak odczytu when stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:07:37 +02:00
rootandClaude Opus 5.5 8146296061 feat(backend): serve the Raspberry Pi BME280 reading at /api/pi-sensor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:07:37 +02:00
rootandClaude Opus 5.5 c21094b4f4 feat(frontend): show the direction of energy flows with arrows
CI / Check (gateway, backend, frontend) (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:05:25 +02:00
root 3b6d073b1f Merge PR #77: feat(gateway): poll the heat pump again soon after a write
CI / Check (gateway, backend, frontend) (push) Successful in 1m20s
2026-10-24 14:01:04 +02:00
root 63c552dfd7 Merge PR #76: feat: choose the away temperatures in Wyjazd
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
2026-10-24 13:59:00 +02:00
rootandClaude Opus 5.5 de19424a7b feat(gateway): poll the heat pump again 3 s after a write or restore
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
The derived targets and setpoint fields stayed old until the next 300 s poll.
The extra poll runs on the device's own poller thread, so it never overlaps
another read, and requests within the delay coalesce into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:57:59 +02:00
rootandClaude Opus 5.5 03b4ee1a48 feat(frontend): choose the away temperatures in Wyjazd
CI / Check (gateway, backend, frontend) (push) Successful in 1m19s
One stepper per preset change under Powrót, lowering only; a setpoint
already at its minimum disables the row and the confirmation. The steppers
of Nastawy and Wyjazd share one component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:55:52 +02:00
rootandClaude Opus 5.5 355dd76555 feat(backend): let a preset take the user's own values
POST /api/presets/{id} accepts optional values per setpoint of the preset,
checked against the gateway's limits before anything is sent, so Wyjazd
can use temperatures other than the defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:54:45 +02:00
root b6997560d2 Merge PR #75: feat: show a change at once and restore a setpoint now
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
Release / Images (push) Successful in 2m58s
2026-10-24 13:37:34 +02:00
rootandClaude Opus 5.5 09042c6338 feat(frontend): Przywróć teraz for pending changes in Ustawienia
CI / Check (gateway, backend, frontend) (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:34:34 +02:00
rootandClaude Opus 5.5 f9fbff4eea feat(backend): POST /api/setpoints/{name}/restore
Forwards to the gateway's restore now and passes its verdict on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:34:34 +02:00
rootandClaude Opus 5.5 7e50d1203b feat(gateway): show the verified value and restore a setpoint on request
GET /commands showed the last poll for up to 5 minutes after a write.
It now shows the value read back after our own write or restore when
that is newer than the poll. POST /commands/restore runs a pending
restore at once through the normal path (conflict check, read-back,
audit with the requester, rate limit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:34:34 +02:00
root 934330e157 Merge PR #74: feat: away holds until a chosen return date (ADR-012)
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
2026-10-24 13:28:16 +02:00
rootandClaude Opus 5.5 2e273f05c2 feat(frontend): pick the return date for Wyjazd
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
Tapping Wyjazd opens a form with a datetime-local picker (from one hour
to 30 days ahead, default in a week at 18:00) and sends the chosen time
with the local offset. A held restore shows its end date, for example
"do 2 lis, 18:00, potem 43 °C".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:25:10 +02:00
rootandClaude Opus 5.5 9a48442220 feat(backend): presets held until a date the user chooses
A preset with "until": "chosen" needs {"until": ...} in POST
/api/presets/{id} and sends each change to the gateway as a held change
(ADR-012). Wyjazd becomes such a preset. A held preset that would not
lower a setpoint is refused up front, so it never stops half way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:22:50 +02:00
rootandClaude Opus 5.5 b3038aeef7 feat(gateway): hold a lowering change until a chosen end without renewals
A command may carry "until" (ISO-8601 with offset). The change is stored
as a restore with leaseUntil = due = until, so it survives a restart and
runs at its end. Held changes must lower the value below both the current
value and the original, end in the future and within 30 days; otherwise
they are rejected without a write. Renewals now only extend a lease.
ADR-012 amends ADR-011 for held changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:22:02 +02:00
191 changed files with 9976 additions and 384 deletions

No files matched your search

+53 -1
View File
@@ -4,6 +4,42 @@ The phone API in Kraków (ADR-008): live state from the gateway, history from In
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
the three values are null (`pi-sensor-stale.json`).
`GET /api/stats?range=7d|30d` (example `src/test/resources/api/stats.json`; 400 otherwise, 502 when InfluxDB is down) is the heat pump per local
day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` with `date`, `compressorHours`, `compressorStarts`, `cwuHours`, `coHours`,
`heaterHours`, `avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
`mode` (`touEnabled` with a Polish sentence), `schedule` (six slots, each from its start to the next one, the last wraps past midnight),
`limits`, and `capacityKwh` = capacity in Ah x a nominal 51.2 V, since the inverter reports Ah only. When stale they are null (`schedule` empty).
`/api/battery` also has `reserve` (`pct`, `floorPct`: the backup reserve of the app and the lowest the user may set) and `editable`:
true when the gateway answers its `/battery/reserve`, i.e. it has battery commands on and `GATEWAY_TOKEN` is right; else `reserve` is null
and `editable` false. `POST /api/battery/schedule` (examples `battery-schedule-request.json`, `battery-failed.json`) takes
`{"enabled"?, "slots": [{"slot": 1-6, "from"?: "HH:MM", "socPct"?, "gridCharge"?}]}` and `POST /api/battery/reserve` takes `{"pct": 60}`.
Same Origin, header and JSON rules as the other writes; `Remote-User` goes on as the requester. The gateway's verdict comes back as
`{status, message, read}`: 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon, 502 failed (`read` is what the
inverter holds), 400 bad body, 503 gateway unreachable or without battery commands (ADR-013).
`GET /api/audit?range=7d` (`24h`, `7d` default, `30d`; 400 otherwise, 502 when InfluxDB fails) is the change history, example
`src/test/resources/api/audit.json`: the gateway's audit rows of the heat pump (`Dom_Pompa.setpoint_audit`) and the battery
(`electricity.deye_settings_audit`), read only, as `entries` newest first: `time`, `source` (`heat_pump`, `battery`), `what` (Polish label;
the Deye registers are named in `DeyeSettingLabels`), `oldValue`/`newValue` (whole numbers, null when not recorded), `unit` (`°C`, `%`,
`HHMM`, `0/1`), `who` (`Remote-User` of the request; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
`GET /api/weather` (example `src/test/resources/api/weather.json`) is the forecast of Open-Meteo for the house and the IMGW warnings that name
Krosno (TERYT 1862 or the powiat 1807): `today`, `tomorrow`, `dayAfter` (`date`, `weatherCode` WMO, Polish `description`, `tMax`, `tMin`,
`precipitationMm`, `sunshineHours`, `pvForecastKwh`), `now.temperature`, `warnings` (`event`, `level` 1-3, `from`, `to`, `text`; the ones that
ended are dropped). `pvFactor` is kWh per MJ/m^2: the Deye plus Fronius energy of the past 14 complete days (from InfluxDB, daily maximum of
`pv_today_kwh` and `energy_day_wh`) over the forecast radiation of the same days, so no tilt or azimuth is needed; `pvForecastKwh` is the factor
times the day's radiation, and both are null with fewer than 5 days that have both values. The forecast is kept 30 min and the warnings 10 min;
when a refresh fails the older answer is served with `stale` true. 503 only when there never was a forecast. The sources are public and need no key.
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
without the two URLs.
@@ -17,7 +53,9 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us
| `GATEWAY_TIMEOUT` | `5` | seconds |
| `INFLUX_TIMEOUT` | `10` | seconds |
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start |
| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast |
| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count |
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
```bash
mvn -f backend/pom.xml clean package
@@ -26,6 +64,20 @@ GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
```
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
pending change of that setpoint now and answers with the gateway's verdict, example
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
pending), 503 gateway unreachable.
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
rules (ADR-009).
@@ -4,23 +4,37 @@ import home.backend.adapters.config.PresetFile;
import home.backend.adapters.gateway.GatewayHttpClient;
import home.backend.adapters.gateway.GatewayHttpCommands;
import home.backend.adapters.http.ApiServer;
import home.backend.adapters.influx.InfluxAuditRepository;
import home.backend.adapters.influx.InfluxHistoryRepository;
import home.backend.adapters.influx.InfluxPvEnergy;
import home.backend.adapters.influx.InfluxSettings;
import home.backend.adapters.weather.ImgwClient;
import home.backend.adapters.weather.OpenMeteoClient;
import home.backend.adapters.weather.WeatherSettings;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
import home.backend.application.LeaseRenewer;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import home.backend.application.WeatherService;
import java.io.IOException;
import java.net.URI;
import java.time.Clock;
import java.time.Duration;
import java.util.Objects;
/** Wires the adapters and services together; the only place that knows all of them. */
final class Backend {
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
private Backend() {
}
@@ -31,14 +45,27 @@ final class Backend {
*/
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
var setpoints = new SetpointService(commands(settings), PresetFile.load(settings.presetsFile()));
var influxSettings = new InfluxSettings(settings.influxUrl(), settings.influxTimeout());
var influx = new InfluxHistoryRepository(influxSettings);
var commands = commands(settings);
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
return new ApiServer(settings.port(),
new HeatPumpService(gateway, clock),
new EnergyService(gateway, clock),
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
new HistoryService(influx, clock), setpoints);
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
new BatteryService(gateway, commands, clock),
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
weather(influxSettings, clock));
}
private static WeatherService weather(InfluxSettings influx, Clock clock) {
var place = WeatherSettings.fromEnvironment(System.getenv());
return new WeatherService(
new OpenMeteoClient(URI.create("https://api.open-meteo.com"), place, WEATHER_TIMEOUT),
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WEATHER_TIMEOUT),
new InfluxPvEnergy(influx), clock);
}
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
@@ -3,10 +3,16 @@ package home.backend.adapters.gateway;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayBattery;
import home.backend.application.GatewayCommands;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import java.io.IOException;
@@ -15,16 +21,38 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands {
/** Sends commands to the gateway's {@code /commands} and {@code /battery} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
/** Restore now: 200 restored, 404 nothing pending, 409 changed meanwhile, 429 too soon, 502 failed. */
private static final Set<Integer> RESTORE_VERDICTS = Set.of(200, 404, 409, 429, 502);
/** Battery change: 200 applied, 422 rejected, 409 stale or changed meanwhile, 429 too soon, 502 failed. */
private static final Set<Integer> BATTERY_VERDICTS = Set.of(200, 422, 409, 429, 502);
@JsonInclude(JsonInclude.Include.NON_NULL)
private record Request(String setpoint, int value, Long minutes) {
private record ScheduleRequest(Boolean enabled, List<SlotRequest> slots) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
private record SlotRequest(int slot, Integer start, Integer soc, Boolean gridCharge) {
}
private record ReserveRequest(int pct) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
private record Request(String setpoint, int value, Long minutes, Instant until) {
}
private record RestoreRequest(String setpoint) {
}
private record Renewal(int renewed) {
@@ -32,10 +60,14 @@ public class GatewayHttpCommands implements GatewayCommands {
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI endpoint;
private final URI renewEndpoint;
private final URI restoreEndpoint;
private final URI reserveEndpoint;
private final URI scheduleEndpoint;
private final String token;
private final Duration timeout;
private final HttpClient http;
@@ -44,6 +76,9 @@ public class GatewayHttpCommands implements GatewayCommands {
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
this.endpoint = baseUri.resolve("/commands");
this.renewEndpoint = baseUri.resolve("/commands/renew");
this.restoreEndpoint = baseUri.resolve("/commands/restore");
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
this.token = token;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
@@ -52,13 +87,21 @@ public class GatewayHttpCommands implements GatewayCommands {
@Override
public CommandResult apply(Command command) throws IOException {
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes));
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes, command.heldUntil()));
HttpRequest.Builder request = request().header("Content-Type", "application/json")
.header("X-Requested-By", command.requestedBy())
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
byte[] body = JSON.writeValueAsBytes(new RestoreRequest(setpoint));
var request = request(restoreEndpoint).header("Content-Type", "application/json")
.header("X-Requested-By", requestedBy).POST(HttpRequest.BodyPublishers.ofByteArray(body));
return JSON.readValue(send(request, RESTORE_VERDICTS), RestoreResult.class);
}
@Override
public SetpointState setpoints() throws IOException {
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
@@ -70,6 +113,30 @@ public class GatewayHttpCommands implements GatewayCommands {
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
}
@Override
public Reserve reserve() throws IOException {
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
var slots = change.slots().stream().map(slot -> new SlotRequest(slot.slot(),
slot.from() == null ? null : slot.from().getHour() * 100 + slot.from().getMinute(),
slot.socPct(), slot.gridCharge())).toList();
return battery(scheduleEndpoint, new ScheduleRequest(change.enabled(), slots), requestedBy);
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
return battery(reserveEndpoint, new ReserveRequest(pct), requestedBy);
}
private BatteryResult battery(URI uri, Object body, String requestedBy) throws IOException {
var request = request(uri).header("Content-Type", "application/json").header("X-Requested-By", requestedBy)
.POST(HttpRequest.BodyPublishers.ofByteArray(JSON.writeValueAsBytes(body)));
return JSON.readValue(send(request, BATTERY_VERDICTS), BatteryResult.class);
}
private HttpRequest.Builder request() throws IOException {
return request(endpoint);
}
@@ -83,14 +150,15 @@ public class GatewayHttpCommands implements GatewayCommands {
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
try {
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
var built = request.build();
var response = http.send(built, HttpResponse.BodyHandlers.ofByteArray());
if (!accepted.contains(response.statusCode())) {
throw new IOException("gateway /commands answered " + response.statusCode());
throw new IOException("gateway " + built.uri().getPath() + " answered " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while calling gateway /commands", e);
throw new IOException("interrupted while calling the gateway", e);
}
}
}
@@ -9,16 +9,26 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import home.backend.application.WeatherService;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.PresetResult;
import home.backend.domain.RestoreResult;
import java.io.IOException;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
@@ -38,12 +48,30 @@ public class ApiServer {
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
private record ReserveRequest(Integer pct) {
}
private record SetpointRequest(String setpoint, Integer value, Double hours) {
}
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
private record PresetRequest(String until, Map<String, Integer> values) {
PresetRequest {
if (values == null) {
values = Map.of();
} else if (values.containsValue(null)) {
throw new IllegalArgumentException("a value must be a number");
}
}
}
private static final String UNREACHABLE = "gateway unreachable";
private static final String UNAVAILABLE = "battery changes are not available: gateway unreachable or not enabled";
private static final String WRITE_MARKER = "home-pump";
private static final String PRESETS_PATH = "/api/presets/";
private static final String SETPOINTS_PATH = "/api/setpoints/";
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
private static final String USER_HEADER = "Remote-User";
private static final String DEFAULT_USER = "phone";
@@ -61,7 +89,9 @@ public class ApiServer {
/** Binds the port now; 0 picks a free one. */
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints) throws IOException {
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather)
throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(executor);
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
@@ -73,13 +103,33 @@ public class ApiServer {
api("/api/heat-pump", query -> heatPump.current());
api("/api/energy", query -> energy.current());
api("/api/energy/details", query -> energyDetails.current());
api("/api/battery", query -> battery.current());
route("/api/battery/schedule", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
api("/api/pi-sensor", query -> piSensor.current());
api("/api/weather", query -> weather.current());
api("/api/health", query -> health.current());
route("/api/history", exchange -> handleHistory(exchange, history));
route("/api/audit", exchange -> handleAudit(exchange, audit));
route("/api/stats", exchange -> handleStats(exchange, stats));
route("/api/setpoints", exchange -> handle(exchange, Map.of(
"GET", () -> getSetpoints(exchange, setpoints),
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
context(SETPOINTS_PATH, exchange -> {
var restore = RESTORE_PATH.matcher(exchange.getRequestURI().getPath());
if (restore.matches()) {
handle(exchange, Map.of("POST", () -> guarded(exchange,
body -> postRestore(exchange, restore.group(1), setpoints))));
} else {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
}
});
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postPreset(exchange, setpoints)))));
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
}
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
@@ -178,15 +228,63 @@ public class ApiServer {
}
}
private static void postPreset(HttpExchange exchange, SetpointService setpoints) throws IOException {
private static void postSchedule(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
try {
var change = JSON.readValue(body, BatteryChange.class);
if (change == null) {
throw new IllegalArgumentException("a change is required");
}
var result = battery.changeSchedule(change, user(exchange));
send(exchange, batteryHttpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"enabled\"?, \"slots\": [{\"slot\", \"from\"?: \"HH:MM\", "
+ "\"socPct\"?, \"gridCharge\"?}]}"));
} catch (IOException e) {
send(exchange, 503, error(UNAVAILABLE));
}
}
private static void postReserve(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
try {
var request = JSON.readValue(body, ReserveRequest.class);
if (request == null || request.pct() == null) {
throw new IllegalArgumentException("pct is required");
}
var result = battery.changeReserve(request.pct(), user(exchange));
send(exchange, batteryHttpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"pct\"}"));
} catch (IOException e) {
send(exchange, 503, error(UNAVAILABLE));
}
}
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
throws IOException {
try {
var result = setpoints.restore(setpoint, user(exchange));
send(exchange, restoreHttpStatus(result.outcome()), result);
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postPreset(HttpExchange exchange, byte[] body, SetpointService setpoints)
throws IOException {
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
try {
var result = setpoints.applyPreset(id, user(exchange));
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
if (result.isPresent()) {
send(exchange, presetHttpStatus(result.get().status()), result.get());
} else {
send(exchange, 404, error("no such preset"));
}
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
+ "for setpoints of the preset"));
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
@@ -200,6 +298,26 @@ public class ApiServer {
};
}
private static int batteryHttpStatus(BatteryResult.Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case STALE -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
return switch (outcome) {
case RESTORED -> 200;
case NOT_PENDING -> 404;
case CONFLICT -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private static int presetHttpStatus(PresetResult.Status status) {
return switch (status) {
case APPLIED -> 200;
@@ -226,6 +344,31 @@ public class ApiServer {
}));
}
private void handleAudit(HttpExchange exchange, AuditService audit) throws IOException {
handle(exchange, Map.of("GET", () -> {
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
try {
send(exchange, 200, audit.audit(query.get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("audit unavailable"));
}
}));
}
private void handleStats(HttpExchange exchange, StatsService stats) throws IOException {
handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, stats.stats(parseQuery(exchange.getRequestURI().getRawQuery()).get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("stats unavailable"));
}
}));
}
private interface Action {
void run() throws IOException;
}
@@ -0,0 +1,88 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.application.AuditRepository;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** The gateway's audit measurements (S3) read from InfluxDB; never written. */
public final class InfluxAuditRepository implements AuditRepository {
private static final ObjectMapper JSON = new ObjectMapper();
private final InfluxClient influx;
public InfluxAuditRepository(InfluxSettings settings) {
this.influx = new InfluxClient(settings);
}
@Override
public List<Row> heatPump(Instant from, Instant to) throws IOException {
return rows("Dom_Pompa", "setpoint_audit", "setpoint", from, to);
}
@Override
public List<Row> battery(Instant from, Instant to) throws IOException {
return rows("electricity", "deye_settings_audit", "register", from, to);
}
private List<Row> rows(String database, String measurement, String targetTag, Instant from, Instant to)
throws IOException {
String query = "SELECT * FROM \"" + measurement + "\" WHERE time >= " + from.toEpochMilli()
+ "ms AND time < " + to.toEpochMilli() + "ms ORDER BY time DESC";
var rows = new ArrayList<Row>();
for (JsonNode result : results(influx.query(database, query))) {
for (JsonNode series : result.path("series")) {
for (JsonNode values : series.path("values")) {
rows.add(row(series.path("columns"), values, targetTag));
}
}
}
return rows;
}
private static JsonNode results(String body) throws IOException {
JsonNode root;
try {
root = JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
failOnError(root);
for (JsonNode result : root.path("results")) {
failOnError(result);
}
return root.path("results");
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
private static Row row(JsonNode columns, JsonNode values, String targetTag) {
Map<String, JsonNode> byName = new HashMap<>();
for (int i = 0; i < columns.size(); i++) {
byName.put(columns.get(i).asText(), values.get(i));
}
return new Row(Instant.ofEpochMilli(byName.get("time").asLong()), text(byName.get(targetTag)),
text(byName.get("requested_by")), text(byName.get("status")),
number(byName.get("old_value")), number(byName.get("new_value")));
}
private static String text(JsonNode node) {
return node == null || node.isNull() ? "" : node.asText();
}
private static Double number(JsonNode node) {
return node == null || !node.isNumber() ? null : node.asDouble();
}
}
@@ -0,0 +1,47 @@
package home.backend.adapters.influx;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
/** Read-only queries to InfluxDB 1.x over its HTTP {@code /query} endpoint; times come back in epoch ms. */
final class InfluxClient {
private final InfluxSettings settings;
private final HttpClient http;
InfluxClient(InfluxSettings settings) {
this.settings = settings;
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
}
String query(String database, String query) throws IOException {
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
.timeout(settings.requestTimeout())
.GET()
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("InfluxDB request interrupted", e);
}
}
private URI queryUri(String database, String query) {
String base = settings.baseUri().toString().replaceAll("/+$", "");
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -1,16 +1,12 @@
package home.backend.adapters.influx;
import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryRepository;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
@@ -20,14 +16,12 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
public final class InfluxHistoryRepository implements HistoryRepository {
public final class InfluxHistoryRepository implements HistoryRepository, HeatPumpSamples {
private final InfluxSettings settings;
private final HttpClient http;
private final InfluxClient influx;
public InfluxHistoryRepository(InfluxSettings settings) {
this.settings = settings;
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
this.influx = new InfluxClient(settings);
}
@Override
@@ -57,39 +51,19 @@ public final class InfluxHistoryRepository implements HistoryRepository {
.toList();
}
@Override
public List<HeatPumpSample> samples(Instant from, Instant to) throws IOException {
String query = InfluxSamples.query(from.toEpochMilli(), to.toEpochMilli());
return InfluxSamples.parse(influx.query(SeriesSources.LEGACY, query));
}
private Map<Long, Double> part(Source source, Instant from, Instant to, long stepMinutes) throws IOException {
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
return InfluxResponse.parse(fetch(source.database(), query));
return InfluxResponse.parse(influx.query(source.database(), query));
}
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
}
private String fetch(String database, String query) throws IOException {
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
.timeout(settings.requestTimeout())
.GET()
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("InfluxDB request interrupted", e);
}
}
private URI queryUri(String database, String query) {
String base = settings.baseUri().toString().replaceAll("/+$", "");
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,51 @@
package home.backend.adapters.influx;
import home.backend.application.PvEnergyRepository;
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Map;
import java.util.TreeMap;
/** Daily PV energy: the Deye's and the Fronius's own day counters, each read as the day's maximum. */
public final class InfluxPvEnergy implements PvEnergyRepository {
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
private static final String DATABASE = "electricity";
private final InfluxClient influx;
public InfluxPvEnergy(InfluxSettings settings) {
this.influx = new InfluxClient(settings);
}
@Override
public Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException {
Map<LocalDate, Double> deye = dailyMax("deye", "pv_today_kwh", from, to);
Map<LocalDate, Double> fronius = dailyMax("fronius", "energy_day_wh", from, to);
Map<LocalDate, Double> total = new TreeMap<>();
deye.forEach((day, kwh) -> {
Double wh = fronius.get(day);
if (wh != null) {
total.put(day, kwh + wh / 1000);
}
});
return total;
}
private Map<LocalDate, Double> dailyMax(String measurement, String field, LocalDate from, LocalDate to)
throws IOException {
String query = "SELECT max(\"" + field + "\") FROM \"" + measurement + "\" WHERE time >= "
+ millis(from) + "ms AND time < " + millis(to) + "ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')";
Map<LocalDate, Double> days = new TreeMap<>();
InfluxResponse.parse(influx.query(DATABASE, query)).forEach((start, max) ->
days.put(Instant.ofEpochMilli(start).atZone(ZONE).toLocalDate(), max));
return days;
}
private static long millis(LocalDate day) {
return day.atStartOfDay(ZONE).toInstant().toEpochMilli();
}
}
@@ -0,0 +1,58 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.domain.HeatPumpSample;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/** The raw {@code heat_pump} rows of an InfluxDB 1.x query. */
final class InfluxSamples {
private static final ObjectMapper JSON = new ObjectMapper();
private InfluxSamples() {
}
static String query(long fromMs, long toMs) {
return "SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
}
/** The rows of {@link #query}, as returned (ascending); empty when there is no data. */
static List<HeatPumpSample> parse(String body) throws IOException {
JsonNode root;
try {
root = JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
failOnError(root);
List<HeatPumpSample> samples = new ArrayList<>();
for (JsonNode result : root.path("results")) {
failOnError(result);
for (JsonNode series : result.path("series")) {
for (JsonNode row : series.path("values")) {
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
number(row, 1), number(row, 2), number(row, 3), number(row, 4),
number(row, 5), number(row, 6)));
}
}
}
return samples;
}
private static Double number(JsonNode row, int column) {
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
}
@@ -8,7 +8,7 @@ import java.util.Map;
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
final class SeriesSources {
private static final String LEGACY = "Dom_Pompa";
static final String LEGACY = "Dom_Pompa";
private static final String ELECTRICITY = "electricity";
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
@@ -0,0 +1,42 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/** GET of a public JSON document, with a timeout on connecting and on the whole request. */
final class HttpJson {
private static final ObjectMapper JSON = new ObjectMapper();
private final HttpClient http;
private final Duration timeout;
HttpJson(Duration timeout) {
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
this.timeout = timeout;
}
JsonNode get(URI uri) throws IOException {
HttpRequest request = HttpRequest.newBuilder(uri).timeout(timeout).GET().build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException(uri.getHost() + " answered HTTP " + response.statusCode());
}
return JSON.readTree(response.body());
} catch (JsonProcessingException e) {
throw new IOException(uri.getHost() + " returned invalid JSON: " + e.getOriginalMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("request to " + uri.getHost() + " interrupted", e);
}
}
}
@@ -0,0 +1,76 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.databind.JsonNode;
import home.backend.application.WarningSource;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.net.URI;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* IMGW's public list of meteorological warnings, kept to those naming one of our TERYT areas. A warning
* that is malformed (no degree or times) is left out rather than failing the others.
*/
public final class ImgwClient implements WarningSource {
private static final ZoneId POLAND = ZoneId.of("Europe/Warsaw");
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final HttpJson http;
private final URI uri;
private final List<String> teryt;
public ImgwClient(URI baseUri, WeatherSettings place, Duration timeout) {
this.http = new HttpJson(timeout);
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/api/data/warningsmeteo");
this.teryt = place.teryt();
}
@Override
public List<Warning> warnings() throws IOException {
JsonNode root = http.get(uri);
if (!root.isArray()) {
throw new IOException("IMGW returned no list of warnings");
}
List<Warning> ours = new ArrayList<>();
for (JsonNode item : root) {
if (covers(item.path("teryt"))) {
warning(item).ifPresent(ours::add);
}
}
return ours;
}
private boolean covers(JsonNode codes) {
for (JsonNode code : codes) {
if (teryt.contains(code.asText())) {
return true;
}
}
return false;
}
private static Optional<Warning> warning(JsonNode item) {
try {
return Optional.of(new Warning(item.path("nazwa_zdarzenia").asText(),
Integer.parseInt(item.path("stopien").asText()),
time(item.path("obowiazuje_od")), time(item.path("obowiazuje_do")),
item.path("tresc").asText()));
} catch (NumberFormatException | DateTimeException e) {
return Optional.empty();
}
}
private static OffsetDateTime time(JsonNode text) {
return LocalDateTime.parse(text.asText(), TIME).atZone(POLAND).toOffsetDateTime();
}
}
@@ -0,0 +1,68 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.databind.JsonNode;
import home.backend.application.ForecastSource;
import home.backend.domain.Forecast;
import home.backend.domain.Forecast.DailyForecast;
import java.io.IOException;
import java.net.URI;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Map;
import java.util.TreeMap;
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 3, and the temperature now. */
public final class OpenMeteoClient implements ForecastSource {
private static final String DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,"
+ "shortwave_radiation_sum,sunshine_duration";
private final HttpJson http;
private final URI uri;
public OpenMeteoClient(URI baseUri, WeatherSettings place, Duration timeout) {
this.http = new HttpJson(timeout);
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/v1/forecast?latitude="
+ place.latitude() + "&longitude=" + place.longitude() + "&daily=" + DAILY
+ "&current=temperature_2m&timezone=Europe%2FWarsaw&past_days=14&forecast_days=3");
}
@Override
public Forecast forecast() throws IOException {
JsonNode root = http.get(uri);
JsonNode daily = root.path("daily");
JsonNode dates = daily.path("time");
if (!dates.isArray() || dates.isEmpty()) {
throw new IOException("Open-Meteo returned no daily forecast");
}
Map<LocalDate, DailyForecast> days = new TreeMap<>();
for (int i = 0; i < dates.size(); i++) {
days.put(date(dates.get(i).asText()), new DailyForecast(
integer(daily, "weather_code", i), number(daily, "temperature_2m_max", i),
number(daily, "temperature_2m_min", i), number(daily, "precipitation_sum", i),
number(daily, "shortwave_radiation_sum", i), number(daily, "sunshine_duration", i)));
}
JsonNode now = root.path("current").path("temperature_2m");
return new Forecast(now.isNumber() ? now.asDouble() : null, days);
}
private static LocalDate date(String text) throws IOException {
try {
return LocalDate.parse(text);
} catch (DateTimeException e) {
throw new IOException("Open-Meteo returned a bad date: " + text, e);
}
}
private static Double number(JsonNode daily, String name, int i) {
JsonNode value = daily.path(name).path(i);
return value.isNumber() ? value.asDouble() : null;
}
private static Integer integer(JsonNode daily, String name, int i) {
JsonNode value = daily.path(name).path(i);
return value.isNumber() ? value.asInt() : null;
}
}
@@ -0,0 +1,35 @@
package home.backend.adapters.weather;
import java.util.List;
import java.util.Map;
/**
* Where the house is and which IMGW areas (TERYT powiat codes) count as ours. Krosno is a city with
* powiat rights, 1862; 1807 is the surrounding powiat krośnieński, which warnings may name instead.
*/
public record WeatherSettings(double latitude, double longitude, List<String> teryt) {
public static WeatherSettings fromEnvironment(Map<String, String> env) {
return new WeatherSettings(
coordinate(env, "WEATHER_LAT", 49.684, 90),
coordinate(env, "WEATHER_LON", 21.770, 180),
List.of(env.getOrDefault("WEATHER_TERYT", "1862,1807").strip().split("\\s*,\\s*")));
}
private static double coordinate(Map<String, String> env, String name, double fallback, double limit) {
String value = env.get(name);
if (value == null || value.isBlank()) {
return fallback;
}
try {
double number = Double.parseDouble(value.strip());
if (Math.abs(number) <= limit) {
return number;
}
} catch (NumberFormatException e) {
// reported below with the same message
}
throw new IllegalArgumentException(name + " must be a number from -" + limit + " to " + limit
+ ", got: " + value);
}
}
@@ -0,0 +1,17 @@
package home.backend.application;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
/** The gateway's audit rows in InfluxDB (S3), read only. */
public interface AuditRepository {
/** One audited write as recorded; {@code target} is the setpoint name or the Deye register. */
record Row(Instant time, String target, String requestedBy, String status, Double oldValue, Double newValue) {
}
List<Row> heatPump(Instant from, Instant to) throws IOException;
List<Row> battery(Instant from, Instant to) throws IOException;
}
@@ -0,0 +1,70 @@
package home.backend.application;
import home.backend.application.AuditRepository.Row;
import home.backend.domain.AuditEntry;
import home.backend.domain.AuditView;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.Map;
import java.util.stream.Stream;
/** S3: who changed what and when, from the gateway's audit rows of the heat pump and of the battery. */
public class AuditService {
private static final String DEFAULT_RANGE = "7d";
private static final Map<String, Duration> RANGES =
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7), "30d", Duration.ofDays(30));
private static final Map<String, String> SETPOINTS = Map.of(
"cwu_economy", "CWU eko", "cwu_comfort", "CWU komfort",
"co_economy", "CO eko", "co_comfort", "CO komfort");
private static final String RESTORE_USER = "restore";
private static final String RESTORE_LABEL = "automatycznie (przywrócenie)";
private final AuditRepository repository;
private final Clock clock;
public AuditService(AuditRepository repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
/**
* @param range {@code 24h}, {@code 7d} or {@code 30d}; null means 7d
* @throws IllegalArgumentException for an unknown range
*/
public AuditView audit(String range) throws IOException {
Duration length = RANGES.get(range == null ? DEFAULT_RANGE : range);
if (length == null) {
throw new IllegalArgumentException("unknown range: " + range);
}
Instant to = clock.instant();
Instant from = to.minus(length);
var heatPump = repository.heatPump(from, to).stream().map(AuditService::heatPumpEntry);
var battery = repository.battery(from, to).stream().map(AuditService::batteryEntry);
return new AuditView(Stream.concat(heatPump, battery)
.sorted(Comparator.comparing(AuditEntry::time).reversed())
.toList());
}
private static AuditEntry heatPumpEntry(Row row) {
return entry(AuditEntry.HEAT_PUMP, row, SETPOINTS.getOrDefault(row.target(), row.target()), "°C");
}
private static AuditEntry batteryEntry(Row row) {
var label = DeyeSettingLabels.of(row.target());
return entry(AuditEntry.BATTERY, row, label.what(), label.unit());
}
private static AuditEntry entry(String source, Row row, String what, String unit) {
return new AuditEntry(row.time(), source, what, whole(row.oldValue()), whole(row.newValue()), unit,
RESTORE_USER.equals(row.requestedBy()) ? RESTORE_LABEL : row.requestedBy(), row.status());
}
private static Integer whole(Double value) {
return value == null ? null : (int) Math.round(value);
}
}
@@ -0,0 +1,109 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.BatteryView.Energy;
import home.backend.domain.BatteryView.Limits;
import home.backend.domain.BatteryView.Mode;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.BatteryView.Slot;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
import static home.backend.application.SnapshotFields.on;
import static home.backend.application.SnapshotFields.whole;
/** F7: the Deye's battery now, and how it is set up. */
public class BatteryService {
/** The settings are read every 10 minutes; three missed reads and they are not current. */
private static final Duration SETTINGS_MAX_AGE = Duration.ofMinutes(30);
private static final int SLOTS = 6;
/** The nominal voltage of a 16-cell lithium pack; the Deye reports capacity in Ah only. */
private static final double NOMINAL_VOLTS = 51.2;
private final GatewayClient gateway;
private final GatewayBattery battery;
private final Clock clock;
public BatteryService(GatewayClient gateway, GatewayBattery battery, Clock clock) {
this.gateway = gateway;
this.battery = battery;
this.clock = clock;
}
public BatteryResult changeSchedule(BatteryChange change, String user) throws IOException {
return battery.changeSchedule(change, user);
}
public BatteryResult changeReserve(int pct, String user) throws IOException {
return battery.changeReserve(pct, user);
}
/** Asked on its own: a gateway without battery commands still serves the live view. */
private Reserve reserve() {
try {
return battery.reserve();
} catch (IOException e) {
return null;
}
}
public BatteryView current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
Live settings = SnapshotFields.live(snapshot, "deye-settings", "deye_settings", clock.instant(),
SETTINGS_MAX_AGE);
Map<String, Double> d = deye.fields();
Map<String, Double> s = settings.fields();
boolean settingsStale = settings.source().stale();
Reserve reserve = reserve();
return new BatteryView(deye.source().time(), deye.source().stale(),
d.get("battery_soc_pct"), d.get("battery_power_w"), d.get("battery_voltage_v"),
d.get("battery_current_a"), d.get("battery_temperature_c"), capacityKwh(s),
new Energy(d.get("battery_charge_today_kwh"), d.get("battery_discharge_today_kwh")),
new Energy(d.get("battery_charge_total_kwh"), d.get("battery_discharge_total_kwh")),
settingsStale ? null : mode(s), settingsStale ? List.of() : schedule(s),
settingsStale ? null : limits(s), settings.source().time(), settingsStale, reserve, reserve != null);
}
private static Double capacityKwh(Map<String, Double> settings) {
Double ah = settings.get("battery_capacity_ah");
return ah == null ? null : Math.round(ah * NOMINAL_VOLTS) / 1000.0;
}
private static Mode mode(Map<String, Double> s) {
boolean tou = on(s, "tou_enabled");
return new Mode(tou, tou ? "Harmonogram: bateria pracuje według przedziałów"
: "Rezerwa na awarię: bateria nie rozładowuje się na co dzień");
}
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first. */
private static List<Slot> schedule(Map<String, Double> s) {
return IntStream.rangeClosed(1, SLOTS).mapToObj(n -> {
String prefix = "prog" + n + "_";
String next = "prog" + (n % SLOTS + 1) + "_start_hhmm";
return new Slot(time(whole(s, prefix + "start_hhmm")), time(whole(s, next)),
whole(s, prefix + "power_w"), whole(s, prefix + "soc_pct"), on(s, prefix + "grid_charge"));
}).toList();
}
private static String time(int hhmm) {
return "%02d:%02d".formatted(hhmm / 100, hhmm % 100);
}
private static Limits limits(Map<String, Double> s) {
return new Limits(whole(s, "max_charge_current_a"), whole(s, "max_discharge_current_a"),
whole(s, "shutdown_soc_pct"), whole(s, "low_soc_pct"), whole(s, "restart_soc_pct"),
on(s, "grid_charge_enabled"), whole(s, "grid_charge_current_a"),
whole(s, "grid_charge_start_soc_pct"));
}
}
@@ -0,0 +1,47 @@
package home.backend.application;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
/** One remote answer kept for a while; when a refresh fails the older answer is served and marked stale. */
final class Cached<T> {
interface Fetch<T> {
T fetch() throws IOException;
}
record Entry<T>(T value, boolean stale) {
}
private final Duration ttl;
private final Clock clock;
private final Fetch<T> source;
private T value;
private Instant fetchedAt;
Cached(Duration ttl, Clock clock, Fetch<T> source) {
this.ttl = ttl;
this.clock = clock;
this.source = source;
}
/** @throws IOException when the refresh fails and there is nothing older to serve */
synchronized Entry<T> get() throws IOException {
Instant now = clock.instant();
if (value != null && now.isBefore(fetchedAt.plus(ttl))) {
return new Entry<>(value, false);
}
try {
value = source.fetch();
fetchedAt = now;
return new Entry<>(value, false);
} catch (IOException e) {
if (value == null) {
throw e;
}
return new Entry<>(value, true);
}
}
}
@@ -0,0 +1,96 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.StatsView.Day;
import home.backend.domain.StatsView.Totals;
import java.time.Duration;
import java.time.LocalDate;
/** What the heat pump did over some span of polls; days are added up into the totals. */
final class DayTally {
private double compressorSeconds;
private double cwuSeconds;
private double coSeconds;
private double heaterSeconds;
private int starts;
private int samples;
private double outdoorSum;
private int outdoorCount;
private Double minCwu;
private Double maxCwu;
void sample(HeatPumpSample s) {
samples++;
if (s.outdoorTemp() != null) {
outdoorSum += s.outdoorTemp();
outdoorCount++;
}
if (s.cwuTemp() != null) {
minCwu = lower(minCwu, s.cwuTemp());
maxCwu = higher(maxCwu, s.cwuTemp());
}
}
/** The outputs of {@code s} held for {@code held}. */
void hold(HeatPumpSample s, Duration held) {
double seconds = held.toMillis() / 1000.0;
compressorSeconds += on(s.compressor()) ? seconds : 0;
cwuSeconds += on(s.cwu()) ? seconds : 0;
coSeconds += on(s.co()) ? seconds : 0;
heaterSeconds += on(s.heater()) ? seconds : 0;
}
void start() {
starts++;
}
void add(DayTally other) {
compressorSeconds += other.compressorSeconds;
cwuSeconds += other.cwuSeconds;
coSeconds += other.coSeconds;
heaterSeconds += other.heaterSeconds;
starts += other.starts;
samples += other.samples;
outdoorSum += other.outdoorSum;
outdoorCount += other.outdoorCount;
if (other.minCwu != null) {
minCwu = lower(minCwu, other.minCwu);
maxCwu = higher(maxCwu, other.maxCwu);
}
}
Day day(LocalDate date) {
if (samples == 0) {
return new Day(date, null, null, null, null, null, null, null, null);
}
return new Day(date, hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
}
Totals totals() {
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
}
static boolean on(Double output) {
return output != null && output > 0;
}
private static Double lower(Double known, double value) {
return known == null ? value : Math.min(known, value);
}
private static Double higher(Double known, double value) {
return known == null ? value : Math.max(known, value);
}
private Double avgOutdoor() {
return outdoorCount == 0 ? null : Math.round(outdoorSum / outdoorCount * 10) / 10.0;
}
private static double hours(double seconds) {
return Math.round(seconds / 3600 * 100) / 100.0;
}
}
@@ -0,0 +1,48 @@
package home.backend.application;
import java.util.Optional;
/** Polish name and unit of the Deye program registers and of the app's reserve, as the gateway audits them. */
final class DeyeSettingLabels {
record Label(String what, String unit) {
}
private static final int TOU_ENABLE = 146;
private static final int FIRST_START = 148;
private static final int FIRST_SOC = 166;
private static final int FIRST_GRID_CHARGE = 172;
private static final int SLOTS = 6;
private DeyeSettingLabels() {
}
static Label of(String register) {
if ("reserve".equals(register)) {
return new Label("Rezerwa baterii", "%");
}
int address;
try {
address = Integer.parseInt(register);
} catch (NumberFormatException e) {
return unknown(register);
}
if (address == TOU_ENABLE) {
return new Label("Harmonogram baterii", "0/1");
}
return slot(address, FIRST_START, "start", "HHMM")
.or(() -> slot(address, FIRST_SOC, "poziom", "%"))
.or(() -> slot(address, FIRST_GRID_CHARGE, "ładowanie z sieci", "0/1"))
.orElseGet(() -> unknown(register));
}
private static Label unknown(String register) {
return new Label("Rejestr " + register, "");
}
private static Optional<Label> slot(int address, int first, String what, String unit) {
return address >= first && address < first + SLOTS
? Optional.of(new Label("Przedział " + (address - first + 1) + ": " + what, unit))
: Optional.empty();
}
}
@@ -0,0 +1,11 @@
package home.backend.application;
import home.backend.domain.Forecast;
import java.io.IOException;
/** The weather forecast service: the past 14 days and the next 3. */
public interface ForecastSource {
Forecast forecast() throws IOException;
}
@@ -0,0 +1,19 @@
package home.backend.application;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import java.io.IOException;
/** Battery settings through the gateway, which checks every change (ADR-013). */
public interface GatewayBattery {
/** @throws IOException when the gateway has no battery commands, or cannot be reached */
Reserve reserve() throws IOException;
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException;
BatteryResult changeReserve(int pct, String requestedBy) throws IOException;
}
@@ -2,6 +2,7 @@ package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import java.io.IOException;
@@ -12,6 +13,9 @@ public interface GatewayCommands {
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
CommandResult apply(Command command) throws IOException;
/** Ends the pending change of a setpoint now; a verdict like {@link #apply}. */
RestoreResult restore(String setpoint, String requestedBy) throws IOException;
SetpointState setpoints() throws IOException;
/** Keeps the gateway's live restores alive; returns how many it extended. */
@@ -0,0 +1,13 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
/** The heat pump polls stored in InfluxDB, oldest first. */
public interface HeatPumpSamples {
List<HeatPumpSample> samples(Instant from, Instant to) throws IOException;
}
@@ -0,0 +1,27 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.PiSensorView;
import java.io.IOException;
import java.time.Clock;
import java.util.Map;
/** The BME280 on the Pi, read by the gateway every 5 minutes. */
public class PiSensorService {
private final GatewayClient gateway;
private final Clock clock;
public PiSensorService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public PiSensorView current() throws IOException {
Live live = SnapshotFields.live(gateway.snapshot(), "bme280", "MEASUREMENT", clock.instant());
Map<String, Double> f = live.fields();
return new PiSensorView(live.source().time(), live.source().stale(),
f.get("temperature"), f.get("humidity"), f.get("pressure"));
}
}
@@ -0,0 +1,12 @@
package home.backend.application;
import java.io.IOException;
import java.time.LocalDate;
import java.util.Map;
/** What the panels produced, from InfluxDB. */
public interface PvEnergyRepository {
/** Deye plus Fronius kWh per local day from {@code from} up to {@code to}; days missing either are absent. */
Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException;
}
@@ -7,15 +7,20 @@ import home.backend.domain.Preset.Change;
import home.backend.domain.PresetResult;
import home.backend.domain.PresetResult.ChangeResult;
import home.backend.domain.PresetResult.ChangeStatus;
import home.backend.domain.RestoreResult;
import home.backend.domain.Setpoint;
import home.backend.domain.SetpointState;
import home.backend.domain.SetpointsView;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
public class SetpointService {
@@ -38,48 +43,86 @@ public class SetpointService {
return gateway.apply(new Command(setpoint, value, duration(hours), user));
}
/** Puts the value from before the pending change back now, instead of waiting for its end. */
public RestoreResult restore(String setpoint, String user) throws IOException {
return gateway.restore(setpoint, user);
}
/**
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
*
* @param until the end the user chose; required by a held preset, not allowed for any other
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
* preset's value
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
* for a setpoint the preset does not change or outside the gateway's limits
*/
public Optional<PresetResult> applyPreset(String id, String user) throws IOException {
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
throws IOException {
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
if (preset.isEmpty()) {
return Optional.empty();
}
List<Change> changes = preset.get().changes();
if (preset.get().isHeld() != (until != null)) {
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
: "preset " + id + " has no end time to choose");
}
List<Change> changes = withValues(preset.get().changes(), values);
List<Setpoint> limits = gateway.setpoints().setpoints();
List<ChangeResult> checked = changes.stream().map(c -> check(c, limits)).toList();
requireWithinLimits(values, limits);
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
return Optional.of(PresetResult.of(checked));
}
return Optional.of(PresetResult.of(run(changes, user)));
return Optional.of(PresetResult.of(run(changes, until, user)));
}
private List<ChangeResult> run(List<Change> changes, String user) {
private static List<Change> withValues(List<Change> changes, Map<String, Integer> values) {
Set<String> own = changes.stream().map(Change::setpoint).collect(Collectors.toSet());
if (!own.containsAll(values.keySet())) {
throw new IllegalArgumentException("values may only be for the setpoints the preset changes: " + own);
}
return changes.stream().map(c -> new Change(c.setpoint(), values.getOrDefault(c.setpoint(), c.value()),
c.hours())).toList();
}
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
for (Setpoint limit : limits) {
Integer value = values.get(limit.name());
if (value != null && !limit.allows(value)) {
throw new IllegalArgumentException(limit.name() + " must be " + limit.min() + "–" + limit.max()
+ " in steps of " + limit.step());
}
}
}
private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
List<ChangeResult> results = new ArrayList<>();
for (Change change : changes) {
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, user));
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, until, user));
}
return results;
}
private ChangeResult attempt(Change change, String user) {
private ChangeResult attempt(Change change, Instant until, String user) {
try {
return ChangeResult.of(apply(change.setpoint(), change.value(), change.hours(), user));
var command = until == null ? new Command(change.setpoint(), change.value(), duration(change.hours()), user)
: new Command(change.setpoint(), change.value(), null, until, user);
return ChangeResult.of(gateway.apply(command));
} catch (IOException e) {
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
}
}
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
private static ChangeResult check(Change change, List<Setpoint> limits) {
String problem = problem(change, limits);
private static ChangeResult check(Change change, boolean held, List<Setpoint> limits) {
String problem = problem(change, held, limits);
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
}
private static String problem(Change change, List<Setpoint> limits) {
private static String problem(Change change, boolean held, List<Setpoint> limits) {
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
if (known.isEmpty()) {
return "preset sets " + change.setpoint() + ", which the gateway does not know";
@@ -88,6 +131,11 @@ public class SetpointService {
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
}
// The gateway refuses a held raise too; asking first keeps a preset from stopping half way.
if (held && known.get().value() != null && change.value() >= known.get().value()) {
return "preset sets " + change.setpoint() + " to " + change.value() + ", which is not below the current "
+ known.get().value();
}
return null;
}
@@ -26,8 +26,13 @@ final class SnapshotFields {
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
return stale(device, reading, now, MAX_AGE);
}
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now,
Duration maxAge) {
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 0;
|| Duration.between(reading.get().time(), now).compareTo(maxAge) > 0;
}
/** One device's fields; empty when it is stale, so nothing old is shown as current. */
@@ -35,8 +40,12 @@ final class SnapshotFields {
}
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now) {
return live(snapshot, device, measurement, now, MAX_AGE);
}
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now, Duration maxAge) {
Optional<Measurement> reading = find(snapshot, device, measurement);
boolean stale = stale(device(snapshot, device), reading, now);
boolean stale = stale(device(snapshot, device), reading, now, maxAge);
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
return new Live(source, stale ? Map.of() : reading.get().fields());
}
@@ -0,0 +1,89 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.StatsView;
import home.backend.domain.StatsView.Day;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* F2: the heat pump's work per local day over the last 7 or 30 days. A poll is taken to hold its
* outputs until the next poll, but at most {@link #MAX_HOLD}, so a gap in the data adds no hours.
*/
public class StatsService {
static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
static final Duration MAX_HOLD = Duration.ofSeconds(600);
private static final Map<String, Integer> RANGE_DAYS = Map.of("7d", 7, "30d", 30);
private final HeatPumpSamples repository;
private final Clock clock;
public StatsService(HeatPumpSamples repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
/**
* @param range {@code 7d} or {@code 30d}
* @throws IllegalArgumentException for an unknown range
*/
public StatsView stats(String range) throws IOException {
Integer length = range == null ? null : RANGE_DAYS.get(range);
if (length == null) {
throw new IllegalArgumentException("unknown range: " + range);
}
Instant now = clock.instant();
LocalDate first = now.atZone(ZONE).toLocalDate().minusDays(length - 1);
Map<LocalDate, DayTally> tallies = new TreeMap<>();
for (int i = 0; i < length; i++) {
tallies.put(first.plusDays(i), new DayTally());
}
tally(repository.samples(first.atStartOfDay(ZONE).toInstant(), now), now, tallies);
DayTally all = new DayTally();
List<Day> days = new ArrayList<>();
tallies.forEach((date, tally) -> {
days.add(tally.day(date));
all.add(tally);
});
return new StatsView(range, days, all.totals());
}
private static void tally(List<HeatPumpSample> samples, Instant now, Map<LocalDate, DayTally> tallies) {
HeatPumpSample previous = null;
for (int i = 0; i < samples.size(); i++) {
HeatPumpSample s = samples.get(i);
DayTally day = tallies.get(s.t().atZone(ZONE).toLocalDate());
day.sample(s);
if (previous != null && previous.compressor() != null && previous.compressor() == 0
&& DayTally.on(s.compressor())) {
day.start();
}
previous = s;
hold(s, i + 1 < samples.size() ? samples.get(i + 1).t() : now, tallies);
}
}
/** Adds the time {@code s} was in force to the days it falls in; a stretch over midnight is split. */
private static void hold(HeatPumpSample s, Instant next, Map<LocalDate, DayTally> tallies) {
Instant end = Duration.between(s.t(), next).compareTo(MAX_HOLD) > 0 ? s.t().plus(MAX_HOLD) : next;
Instant at = s.t();
while (at.isBefore(end)) {
LocalDate date = at.atZone(ZONE).toLocalDate();
Instant midnight = date.plusDays(1).atStartOfDay(ZONE).toInstant();
Instant stop = midnight.isBefore(end) ? midnight : end;
tallies.get(date).hold(s, Duration.between(at, stop));
at = stop;
}
}
}
@@ -0,0 +1,12 @@
package home.backend.application;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.util.List;
/** Weather warnings that cover the house, whether in force now or announced. */
public interface WarningSource {
List<Warning> warnings() throws IOException;
}
@@ -0,0 +1,98 @@
package home.backend.application;
import home.backend.application.Cached.Entry;
import home.backend.domain.Forecast;
import home.backend.domain.Forecast.DailyForecast;
import home.backend.domain.PvCalibration;
import home.backend.domain.WeatherCodes;
import home.backend.domain.WeatherView;
import home.backend.domain.WeatherView.Day;
import home.backend.domain.WeatherView.Now;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Forecast, PV estimate and warnings, each kept for a while so the phones do not load the sources. */
public class WeatherService {
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
private static final int CALIBRATION_DAYS = 14;
private record Calibrated(Forecast forecast, Double factor) {
}
private final Clock clock;
private final PvEnergyRepository pv;
private final Cached<Calibrated> forecast;
private final Cached<List<Warning>> warnings;
public WeatherService(ForecastSource forecastSource, WarningSource warningSource, PvEnergyRepository pv,
Clock clock) {
this.clock = clock;
this.pv = pv;
this.forecast = new Cached<>(Duration.ofMinutes(30), clock, () -> calibrated(forecastSource.forecast()));
this.warnings = new Cached<>(Duration.ofMinutes(10), clock, warningSource::warnings);
}
/** @throws IOException when there is no forecast at all; warnings alone may be missing. */
public WeatherView current() throws IOException {
Entry<Calibrated> weather = forecast.get();
Entry<List<Warning>> warned = warnings();
LocalDate today = LocalDate.now(clock.withZone(ZONE));
Calibrated w = weather.value();
return new WeatherView(clock.instant(),
day(w, today), day(w, today.plusDays(1)), day(w, today.plusDays(2)),
new Now(w.forecast().temperature()), inForce(warned.value()), w.factor(),
weather.stale() || warned.stale());
}
private Entry<List<Warning>> warnings() {
try {
return warnings.get();
} catch (IOException e) {
return new Entry<>(List.of(), true);
}
}
private List<Warning> inForce(List<Warning> all) {
return all.stream().filter(w -> w.to().toInstant().isAfter(clock.instant())).toList();
}
private Calibrated calibrated(Forecast fetched) {
LocalDate today = LocalDate.now(clock.withZone(ZONE));
Map<LocalDate, Double> radiation = new HashMap<>();
fetched.days().forEach((date, day) -> {
if (day.radiationMj() != null) {
radiation.put(date, day.radiationMj());
}
});
try {
var measured = pv.dailyKwh(today.minusDays(CALIBRATION_DAYS), today);
return new Calibrated(fetched, PvCalibration.factor(measured, radiation));
} catch (IOException e) {
return new Calibrated(fetched, null);
}
}
private static Day day(Calibrated w, LocalDate date) throws IOException {
DailyForecast d = w.forecast().days().get(date);
if (d == null) {
throw new IOException("forecast has no " + date);
}
Double kwh = w.factor() == null || d.radiationMj() == null ? null : round(w.factor() * d.radiationMj());
Double sunshine = d.sunshineSeconds() == null ? null : round(d.sunshineSeconds() / 3600);
return new Day(date, d.weatherCode(), WeatherCodes.describe(d.weatherCode()), d.tMax(), d.tMin(),
d.precipitationMm(), sunshine, kwh);
}
private static double round(double value) {
return Math.round(value * 10) / 10.0;
}
}
@@ -0,0 +1,14 @@
package home.backend.domain;
import java.time.Instant;
/**
* One audited write. {@code unit} is "°C", "%", "HHMM" (a clock time written as 1830) or "0/1" (off/on);
* the values are null when the gateway did not record them.
*/
public record AuditEntry(Instant time, String source, String what, Integer oldValue, Integer newValue,
String unit, String who, String status) {
public static final String HEAT_PUMP = "heat_pump";
public static final String BATTERY = "battery";
}
@@ -0,0 +1,7 @@
package home.backend.domain;
import java.util.List;
/** The audited writes of the heat pump and the battery, newest first. */
public record AuditView(List<AuditEntry> entries) {
}
@@ -0,0 +1,19 @@
package home.backend.domain;
import java.time.LocalTime;
import java.util.List;
/**
* A change of the Time of Use switch and of single program slots (1 to 6); a null field stays as it is.
* The gateway checks every rule (ADR-013).
*/
public record BatteryChange(Boolean enabled, List<Slot> slots) {
public BatteryChange {
slots = slots == null ? List.of() : List.copyOf(slots);
}
/** {@code from} is the slot's start, to the minute. */
public record Slot(int slot, LocalTime from, Integer socPct, Boolean gridCharge) {
}
}
@@ -0,0 +1,13 @@
package home.backend.domain;
import java.util.Map;
/**
* The gateway's verdict on a battery change, passed on unchanged. REJECTED: refused by the rules, nothing
* written. STALE: the inverter's settings are unknown or changed meanwhile, nothing written. TOO_SOON: S4.
* FAILED: a write or its read-back went wrong; {@code read} has the registers as read back.
*/
public record BatteryResult(Status status, String message, Map<Integer, Integer> read) {
public enum Status { APPLIED, REJECTED, STALE, TOO_SOON, FAILED }
}
@@ -0,0 +1,39 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* The Deye's battery (F7), read-only. Power in W, + discharge / − charge. The live values
* ({@code time}, {@code stale}) come from the Deye's readings and are null when stale; the settings
* ({@code settingsTime}, {@code settingsStale}: mode, schedule, limits, capacity) come from their own,
* slower reading and are null (empty schedule) when stale. The capacity assumes the nominal 51.2 V.
* {@code reserve} is the app's backup reserve (ADR-013) and {@code editable} says the gateway accepts
* changes; they are null and false when it does not.
*/
public record BatteryView(Instant time, boolean stale, Double socPct, Double powerW, Double voltageV,
Double currentA, Double temperatureC, Double capacityKwh, Energy today, Energy total, Mode mode,
List<Slot> schedule, Limits limits, Instant settingsTime, boolean settingsStale, Reserve reserve,
boolean editable) {
/** The share of the battery kept for a power cut, and the lowest the user may set. */
public record Reserve(int pct, int floorPct) {
}
/** Battery energy in kWh. */
public record Energy(Double chargeKwh, Double dischargeKwh) {
}
/** Time of use on means the battery follows the schedule; off means backup only. */
public record Mode(boolean touEnabled, String description) {
}
/** A program slot, {@code from} and {@code to} as HH:MM; it runs until the next slot starts. */
public record Slot(String from, String to, int powerW, int socPct, boolean gridCharge) {
}
public record Limits(Integer maxChargeA, Integer maxDischargeA, Integer shutdownSocPct,
Integer lowSocPct, Integer restartSocPct, boolean gridChargeEnabled, Integer gridChargeCurrentA,
Integer gridChargeStartSocPct) {
}
}
@@ -1,9 +1,18 @@
package home.backend.domain;
import java.time.Duration;
import java.time.Instant;
/** Set a setpoint; with a duration the gateway restores the old value afterwards, else it stays. */
public record Command(String setpoint, int value, Duration duration, String requestedBy) {
/**
* Set a setpoint. With a duration the gateway restores the old value afterwards; with
* {@code heldUntil} it holds a lowering value until then without renewals (ADR-012); else it stays
* while the backend renews the lease (ADR-011).
*/
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
public Command(String setpoint, int value, Duration duration, String requestedBy) {
this(setpoint, value, duration, null, requestedBy);
}
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
public static final int MAX_HOURS = 72;
@@ -0,0 +1,13 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.Map;
/** What the forecast service says: the temperature now and a record per calendar day, past days included. */
public record Forecast(Double temperature, Map<LocalDate, DailyForecast> days) {
/** Radiation in MJ/m² per day, sunshine in seconds; any value may be missing. */
public record DailyForecast(Integer weatherCode, Double tMax, Double tMin, Double precipitationMm,
Double radiationMj, Double sunshineSeconds) {
}
}
@@ -0,0 +1,8 @@
package home.backend.domain;
import java.time.Instant;
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co, Double heater,
Double outdoorTemp, Double cwuTemp) {
}
@@ -0,0 +1,11 @@
package home.backend.domain;
import java.time.Instant;
/**
* The BME280 sensor on the Raspberry Pi: °C, % and hPa. When it is stale (device down, no reading
* or older than 2 × 300 s) the values are null and {@code stale} is true; {@code time} is null
* without any reading.
*/
public record PiSensorView(Instant time, boolean stale, Double temperature, Double humidity, Double pressure) {
}
@@ -1,9 +1,19 @@
package home.backend.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
/** A named one-tap bundle of setpoint changes (F3), e.g. boost or away. */
public record Preset(String id, String name, List<Change> changes) {
/**
* A named one-tap bundle of setpoint changes (F3), e.g. boost or away. {@code until} is null, or
* {@link #CHOSEN}: the user picks the end on a calendar and the gateway holds the changes until
* then without renewals (ADR-012).
*/
public record Preset(String id, String name, @JsonInclude(JsonInclude.Include.NON_NULL) String until,
List<Change> changes) {
public static final String CHOSEN = "chosen";
/** {@code hours} null means "until changed back". */
public record Change(String setpoint, int value, Double hours) {
@@ -18,6 +28,11 @@ public record Preset(String id, String name, List<Change> changes) {
}
}
@JsonIgnore
public boolean isHeld() {
return CHOSEN.equals(until);
}
public Preset {
if (id == null || id.isBlank() || name == null || name.isBlank()) {
throw new IllegalArgumentException("a preset needs an id and a name");
@@ -25,5 +40,11 @@ public record Preset(String id, String name, List<Change> changes) {
if (changes == null || changes.isEmpty()) {
throw new IllegalArgumentException("preset " + id + " has no changes");
}
if (until != null && !until.equals(CHOSEN)) {
throw new IllegalArgumentException("preset " + id + ": until can only be \"" + CHOSEN + "\"");
}
if (CHOSEN.equals(until) && changes.stream().anyMatch(change -> change.hours() != null)) {
throw new IllegalArgumentException("preset " + id + " is held until a chosen time, so no hours");
}
}
}
@@ -0,0 +1,32 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.Map;
/**
* The PV output per unit of radiation, found from past days instead of the panels' tilt and azimuth:
* measured kWh over forecast MJ/m², summed over the days that have both.
*/
public final class PvCalibration {
static final int MIN_DAYS = 5;
private PvCalibration() {
}
/** Null with fewer than {@value MIN_DAYS} days having both values or no radiation at all. */
public static Double factor(Map<LocalDate, Double> measuredKwh, Map<LocalDate, Double> radiationMj) {
double kwh = 0;
double mj = 0;
int days = 0;
for (var day : measuredKwh.entrySet()) {
Double radiation = radiationMj.get(day.getKey());
if (radiation != null) {
kwh += day.getValue();
mj += radiation;
days++;
}
}
return days < MIN_DAYS || mj <= 0 ? null : kwh / mj;
}
}
@@ -0,0 +1,11 @@
package home.backend.domain;
/** The gateway's answer to "restore now", passed on to the phone unchanged. */
public record RestoreResult(Outcome outcome, String message) {
/**
* NOT_PENDING: nothing to restore. CONFLICT: the register was changed meanwhile, so it was left alone.
* TOO_SOON: the gateway's rate limit. FAILED: the restore stays pending and is retried.
*/
public enum Outcome { RESTORED, NOT_PENDING, CONFLICT, TOO_SOON, FAILED }
}
@@ -0,0 +1,19 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.List;
/**
* Heat pump work per local day (F2). Hours are approximated from the polls, starts are counted
* between polls, so short cycles are missed. A day without polls has null values.
*/
public record StatsView(String range, List<Day> days, Totals totals) {
public record Day(LocalDate date, Double compressorHours, Integer compressorStarts, Double cwuHours,
Double coHours, Double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
public record Totals(double compressorHours, int compressorStarts, double cwuHours, double coHours,
double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
}
@@ -0,0 +1,37 @@
package home.backend.domain;
/** WMO weather interpretation codes as Polish text. */
public final class WeatherCodes {
private WeatherCodes() {
}
public static String describe(Integer code) {
if (code == null) {
return "Brak danych";
}
return switch (code) {
case 0 -> "Bezchmurnie";
case 1 -> "Przeważnie słonecznie";
case 2 -> "Częściowe zachmurzenie";
case 3 -> "Pochmurno";
case 45, 48 -> "Mgła";
case 51, 53, 55 -> "Mżawka";
case 56, 57 -> "Marznąca mżawka";
case 61 -> "Słaby deszcz";
case 63 -> "Deszcz";
case 65 -> "Silny deszcz";
case 66, 67 -> "Marznący deszcz";
case 71 -> "Słaby śnieg";
case 73 -> "Śnieg";
case 75 -> "Intensywny śnieg";
case 77 -> "Ziarna śnieżne";
case 80, 81 -> "Przelotny deszcz";
case 82 -> "Ulewa";
case 85, 86 -> "Przelotny śnieg";
case 95 -> "Burza";
case 96, 99 -> "Burza z gradem";
default -> "Brak danych";
};
}
}
@@ -0,0 +1,27 @@
package home.backend.domain;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Weather at the house: three days, the temperature now and the IMGW warnings in force or announced.
* {@code pvFactor} (kWh per MJ/m² of forecast radiation) and every {@code pvForecastKwh} are null while
* there are too few measured days to calibrate. {@code stale} means part of it is an older answer
* because a source could not be reached.
*/
public record WeatherView(Instant time, Day today, Day tomorrow, Day dayAfter, Now now,
List<Warning> warnings, Double pvFactor, boolean stale) {
public record Day(LocalDate date, Integer weatherCode, String description, Double tMax, Double tMin,
Double precipitationMm, Double sunshineHours, Double pvForecastKwh) {
}
public record Now(Double temperature) {
}
/** {@code level} is IMGW's degree 1 to 3. */
public record Warning(String event, int level, OffsetDateTime from, OffsetDateTime to, String text) {
}
}
@@ -7,6 +7,7 @@
{
"id": "away",
"name": "Wyjazd",
"until": "chosen",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
@@ -151,6 +151,11 @@ class BackendTest {
assertServesExactly("/api/heat-pump", "heat-pump");
}
@Test
void servesPiSensorFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/pi-sensor", "pi-sensor");
}
@Test
void servesEnergyFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/energy", "energy");
@@ -28,8 +28,8 @@ class PresetFileTest {
var presets = PresetFile.load(null);
assertEquals(List.of(
new Preset("cwu-now", "Ciepła woda teraz", List.of(new Change("cwu_economy", 45, 2.0))),
new Preset("away", "Wyjazd", List.of(new Change("cwu_comfort", 10, null),
new Preset("cwu-now", "Ciepła woda teraz", null, List.of(new Change("cwu_economy", 45, 2.0))),
new Preset("away", "Wyjazd", "chosen", List.of(new Change("cwu_comfort", 10, null),
new Change("co_comfort", 15, null)))), presets);
}
@@ -39,7 +39,7 @@ class PresetFileTest {
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
"""));
assertEquals(List.of(new Preset("bath", "Kąpiel", List.of(new Change("cwu_economy", 48, 1.5)))), presets);
assertEquals(List.of(new Preset("bath", "Kąpiel", null, List.of(new Change("cwu_economy", 48, 1.5)))), presets);
}
@Test
@@ -51,6 +51,8 @@ class PresetFileTest {
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"tomorrow\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"chosen\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 2}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
Path file = file(json);
@@ -2,9 +2,14 @@ package home.backend.adapters.gateway;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.Command;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.RestoreResult.Outcome;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -18,6 +23,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
@@ -59,16 +65,20 @@ class GatewayHttpCommandsTest {
}
private void answerCommands() {
server.createContext("/commands", exchange -> {
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
exchange.getRequestHeaders().getFirst("Authorization"),
exchange.getRequestHeaders().getFirst("X-Requested-By"),
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
for (String path : List.of("/commands", "/battery")) {
server.createContext(path, this::answer);
}
}
private void answer(HttpExchange exchange) throws IOException {
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
exchange.getRequestHeaders().getFirst("Authorization"),
exchange.getRequestHeaders().getFirst("X-Requested-By"),
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
private GatewayHttpCommands commands(String token) {
@@ -95,6 +105,15 @@ class GatewayHttpCommandsTest {
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
}
@Test
void aHeldChangeSendsTheEndAsUntil() throws Exception {
serve(200, contract("response-applied.json"));
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"), "phone"));
assertEquals(contractJson("request-held.json"), contractJson(seen.getFirst().body()));
}
@Test
void awayHasNoMinutes() throws Exception {
serve(200, contract("response-applied.json"));
@@ -144,6 +163,47 @@ class GatewayHttpCommandsTest {
assertNull(commands("s3cret-token").setpoints().restores().getFirst().due());
}
@Test
void restorePostsTheSetpointWithTokenAndUser() throws Exception {
serve(200, contract("response-restored.json"));
var result = commands("s3cret-token").restore("cwu_economy", "anna");
assertEquals(Outcome.RESTORED, result.outcome());
assertEquals("POST", seen.getFirst().method());
assertEquals("/commands/restore", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals("anna", seen.getFirst().requestedBy());
assertEquals(contractJson("request-restore.json"), contractJson(seen.getFirst().body()));
}
@Test
void aConflictOnRestoreIsAVerdictNotAnError() throws Exception {
serve(409, contract("response-restore-conflict.json"));
assertEquals(Outcome.CONFLICT, commands("s3cret-token").restore("cwu_economy", "anna").outcome());
}
@Test
void everyRestoreStatusOfTheGatewayIsAVerdict() throws Exception {
var expected = java.util.Map.of(404, Outcome.NOT_PENDING, 429, Outcome.TOO_SOON, 502, Outcome.FAILED);
for (var entry : expected.entrySet()) {
serve(entry.getKey(), "{\"outcome\":\"" + entry.getValue() + "\",\"message\":\"x\"}");
assertEquals(entry.getValue(), commands("s3cret-token").restore("cwu_economy", "anna").outcome());
}
}
@Test
void restoreFailsWithoutAVerdictOrToken() {
serve(503, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").restore("cwu_economy", "anna"));
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).restore("cwu_economy", "anna"));
assertEquals(seenBefore, seen.size());
}
@Test
void renewPostsToRenewWithTheTokenAndReturnsHowManyWereExtended() throws Exception {
serve(200, "{\"renewed\":2}");
@@ -165,6 +225,79 @@ class GatewayHttpCommandsTest {
assertEquals(seenBefore, seen.size());
}
@Test
void readsTheBatteryReserveWithTheToken() throws Exception {
serve(200, contract("battery-reserve.json"));
var reserve = commands("s3cret-token").reserve();
assertEquals(new BatteryView.Reserve(50, 30), reserve);
assertEquals("GET", seen.getFirst().method());
assertEquals("/battery/reserve", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
}
@Test
void sendsAScheduleChangeAsTheGatewayExpectsIt() throws Exception {
serve(200, contract("response-battery-applied.json"));
var change = new BatteryChange(true, List.of(new BatteryChange.Slot(2, LocalTime.of(9, 30), 90, true),
new BatteryChange.Slot(3, null, 85, null)));
var result = commands("s3cret-token").changeSchedule(change, "anna");
assertEquals(BatteryResult.Status.APPLIED, result.status());
assertEquals(java.util.Map.of(167, 90, 172, 1), result.read());
assertEquals("POST", seen.getFirst().method());
assertEquals("/battery/schedule", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals("anna", seen.getFirst().requestedBy());
assertEquals(contractJson("request-battery-schedule.json"), contractJson(seen.getFirst().body()));
}
@Test
void sendsAReserveChange() throws Exception {
serve(200, contract("response-battery-applied.json"));
commands("s3cret-token").changeReserve(60, "anna");
assertEquals("/battery/reserve", seen.getFirst().path());
assertEquals(contractJson("{\"pct\":60}"), contractJson(seen.getFirst().body()));
}
@Test
void everyBatteryStatusOfTheGatewayIsAVerdict() throws Exception {
var expected = java.util.Map.of(422, BatteryResult.Status.REJECTED, 409, BatteryResult.Status.STALE,
429, BatteryResult.Status.TOO_SOON, 502, BatteryResult.Status.FAILED);
for (var entry : expected.entrySet()) {
serve(entry.getKey(), "{\"status\":\"" + entry.getValue() + "\",\"message\":\"x\",\"read\":{}}");
assertEquals(entry.getValue(), commands("s3cret-token").changeReserve(60, "anna").status());
}
}
@Test
void aFailedBatteryWriteCarriesWhatWasRead() throws Exception {
serve(502, contract("response-battery-failed.json"));
var result = commands("s3cret-token").changeSchedule(new BatteryChange(null, null), "anna");
assertEquals(BatteryResult.Status.FAILED, result.status());
assertEquals(java.util.Map.of(167, 80), result.read());
}
@Test
void batteryCallsFailWithoutAVerdictOrToken() {
for (int status : List.of(400, 401, 404, 503)) {
serve(status, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").reserve(), "GET " + status);
assertThrows(IOException.class, () -> commands("s3cret-token").changeReserve(60, "anna"), "POST " + status);
}
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).reserve());
assertThrows(IOException.class, () -> commands(null).changeReserve(60, "anna"));
assertEquals(seenBefore, seen.size());
}
@Test
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
@@ -2,25 +2,41 @@ package home.backend.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.application.AuditRepository;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.adapters.config.PresetFile;
import home.backend.application.GatewayClient;
import home.backend.application.GatewayBattery;
import home.backend.application.GatewayCommands;
import home.backend.application.SetpointService;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.RestoreResult;
import home.backend.domain.RestoreResult.Outcome;
import home.backend.domain.Restore;
import home.backend.domain.Setpoint;
import home.backend.domain.SetpointState;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryRepository;
import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.application.WeatherService;
import home.backend.domain.Forecast;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.WeatherView.Warning;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -36,11 +52,14 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -55,10 +74,17 @@ class ApiServerTest {
private GatewaySnapshot snapshot;
private boolean gatewayDown;
private boolean historyDown;
private boolean auditDown;
private HeatPumpSamples samples;
private RuntimeException gatewayBug;
private final List<Command> sent = new ArrayList<>();
private Status verdict;
private Outcome restoreOutcome;
private final List<String> restored = new ArrayList<>();
private boolean commandsDown;
private boolean batteryDown;
private BatteryResult.Status batteryVerdict;
private final List<String> batterySent = new ArrayList<>();
private int answersBeforeDrop;
private ApiServer server;
@@ -82,7 +108,18 @@ class ApiServerTest {
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
};
samples = (from, to) -> {
if (historyDown) {
throw new IOException("influx down");
}
return List.of(
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 0.0, 8.0, 44.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 0.0, 8.5, 43.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 100.0, 9.0, 45.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 0.0, 9.0, 46.0));
};
verdict = Status.APPLIED;
restoreOutcome = Outcome.RESTORED;
answersBeforeDrop = Integer.MAX_VALUE;
GatewayCommands commands = new GatewayCommands() {
@Override
@@ -99,6 +136,15 @@ class ApiServerTest {
};
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
if (commandsDown) {
throw new IOException("tunnel down");
}
restored.add(setpoint + " by " + requestedBy);
return new RestoreResult(restoreOutcome, "restored to 8");
}
@Override
public SetpointState setpoints() throws IOException {
if (commandsDown) {
@@ -116,11 +162,49 @@ class ApiServerTest {
return 0;
}
};
batteryDown = false;
batteryVerdict = BatteryResult.Status.APPLIED;
GatewayBattery batteryCommands = new GatewayBattery() {
@Override
public Reserve reserve() throws IOException {
if (batteryDown) {
throw new IOException("gateway /battery/reserve answered 404");
}
return new Reserve(50, 30);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
return answer("schedule " + change + " by " + requestedBy);
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
return answer("reserve " + pct + " by " + requestedBy);
}
private BatteryResult answer(String what) throws IOException {
if (batteryDown) {
throw new IOException("tunnel down");
}
batterySent.add(what);
return switch (batteryVerdict) {
case APPLIED -> new BatteryResult(batteryVerdict, "applied", Map.of(167, 90));
case FAILED -> new BatteryResult(batteryVerdict,
"registers not as written: [167]; not retried", Map.of(167, 80));
default -> new BatteryResult(batteryVerdict, "nope", Map.of());
};
}
};
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
new SetpointService(commands, PresetFile.load(null)));
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
new BatteryService(gateway, batteryCommands, clock),
new AuditService(auditRows(), clock), new StatsService(samples, clock),
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
(from, to) -> exampleMeasuredPv(), clock));
server.start();
}
@@ -129,6 +213,27 @@ class ApiServerTest {
server.stop();
}
private AuditRepository auditRows() {
return new AuditRepository() {
@Override
public List<Row> heatPump(Instant from, Instant to) throws IOException {
if (auditDown) {
throw new IOException("influx down");
}
return List.of(
new Row(Instant.parse("2026-10-24T08:00:00Z"), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
new Row(Instant.parse("2026-10-23T07:00:00Z"), "co_comfort", "mariusz", "REJECTED", null, 22.0));
}
@Override
public List<Row> battery(Instant from, Instant to) {
return List.of(
new Row(Instant.parse("2026-10-24T09:30:00Z"), "reserve", "mariusz", "APPLIED", 50.0, 60.0),
new Row(Instant.parse("2026-10-23T18:00:00Z"), "174", "mariusz", "FAILED", 0.0, 1.0));
}
};
}
private static GatewaySnapshot exampleSnapshot() {
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
@@ -140,10 +245,13 @@ class ApiServerTest {
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
"load_today_kwh", 24.2);
"load_today_kwh", 24.2));
deye.putAll(Map.of("battery_voltage_v", 56.49, "battery_current_a", -0.05, "battery_temperature_c", 19.2,
"battery_charge_today_kwh", 0.5, "battery_discharge_today_kwh", 0.1,
"battery_charge_total_kwh", 453.7, "battery_discharge_total_kwh", 162.1));
return new GatewaySnapshot("v1.2.1", List.of(
new DeviceSnapshot("heat-pump", true, pumpTime, null,
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
@@ -155,7 +263,28 @@ class ApiServerTest {
new DeviceSnapshot("house-meter", true, energyTime, null,
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
"frequency", 50.01, "import_energy_active", 67514.27), energyTime)))));
"frequency", 50.01, "import_energy_active", 67514.27), energyTime))),
new DeviceSnapshot("deye-settings", true, energyTime, null,
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
new DeviceSnapshot("bme280", true, energyTime, null,
List.of(new Measurement("measurement", "MEASUREMENT",
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8), energyTime)))));
}
/** The settings read from the inverter on 2026-10-24: time of use off, six 4-hour slots. */
private static Map<String, Double> deyeSettings() {
Map<String, Double> settings = new HashMap<>(Map.of("battery_capacity_ah", 280.0, "tou_enabled", 0.0,
"max_charge_current_a", 80.0, "max_discharge_current_a", 120.0, "shutdown_soc_pct", 20.0,
"low_soc_pct", 35.0, "restart_soc_pct", 50.0, "grid_charge_enabled", 1.0,
"grid_charge_current_a", 40.0, "grid_charge_start_soc_pct", 30.0));
double[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int slot = 1; slot <= 6; slot++) {
settings.put("prog" + slot + "_start_hhmm", starts[slot - 1]);
settings.put("prog" + slot + "_power_w", 10000.0);
settings.put("prog" + slot + "_soc_pct", 80.0);
settings.put("prog" + slot + "_grid_charge", 0.0);
}
return settings;
}
private HttpResponse<String> get(String path) throws Exception {
@@ -164,6 +293,10 @@ class ApiServerTest {
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
private static String awayRequest() throws IOException {
return example("preset-away-request").toString();
}
private static JsonNode example(String name) throws IOException {
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
return JSON.readTree(in);
@@ -218,16 +351,196 @@ class ApiServerTest {
assertMatchesExample("/api/energy", "energy-deye-stale");
}
@Test
void batteryMatchesExample() throws Exception {
assertMatchesExample("/api/battery", "battery");
}
@Test
void batteryWithStaleSettingsMatchesExample() throws Exception {
Instant old = Instant.parse("2026-10-23T21:00:00Z");
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
devices.set(devices.size() - 2, new DeviceSnapshot("deye-settings", true, old, null,
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), old))));
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/battery", "battery-settings-stale");
}
@Test
void batteryWithoutGatewayCommandsIsNotEditable() throws Exception {
batteryDown = true;
var body = JSON.readTree(get("/api/battery").body());
assertTrue(body.get("reserve").isNull());
assertEquals(false, body.get("editable").asBoolean());
assertEquals(99.0, body.get("socPct").asDouble());
}
@Test
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
var response = post("/api/battery/schedule", example("battery-schedule-request").toString(),
"Remote-User", "anna");
assertEquals(200, response.statusCode());
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
new BatteryChange.Slot(2, java.time.LocalTime.of(9, 30), 90, true),
new BatteryChange.Slot(3, null, 85, null))) + " by anna"), batterySent);
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
JSON.readTree(response.body()));
}
@Test
void reserveChangeGoesToTheGateway() throws Exception {
var response = post("/api/battery/reserve", example("battery-reserve-request").toString());
assertEquals(200, response.statusCode());
assertEquals(List.of("reserve 60 by phone"), batterySent);
}
@Test
void batteryStatusFollowsTheGatewaysVerdict() throws Exception {
var expected = Map.of(BatteryResult.Status.REJECTED, 422, BatteryResult.Status.STALE, 409,
BatteryResult.Status.TOO_SOON, 429, BatteryResult.Status.FAILED, 502);
for (var entry : expected.entrySet()) {
batteryVerdict = entry.getKey();
assertEquals(entry.getValue(), post("/api/battery/reserve", "{\"pct\":60}").statusCode());
assertEquals(entry.getValue(), post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
}
batteryVerdict = BatteryResult.Status.FAILED;
assertEquals(example("battery-failed"),
JSON.readTree(post("/api/battery/schedule", "{\"enabled\":false}").body()));
}
@Test
void batteryChangeWithTheGatewayDownIs503() throws Exception {
batteryDown = true;
assertEquals(503, post("/api/battery/reserve", "{\"pct\":60}").statusCode());
assertEquals(503, post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
}
@Test
void badBatteryRequestsAre400AndSendNothing() throws Exception {
for (String body : List.of("", "not json", "[]", "{}", "{\"pct\":\"60\"}", "{\"pct\":60.5}", "{\"pct\":null}",
"{\"pct\":60,\"x\":1}")) {
assertEquals(400, post("/api/battery/reserve", body).statusCode(), body);
}
for (String body : List.of("", "not json", "[]", "{\"enabled\":\"yes\"}", "{\"slots\":[{\"slot\":1,\"from\":\"9h\"}]}",
"{\"slots\":[{\"slot\":1,\"from\":\"25:00\"}]}", "{\"slots\":[{\"slot\":1,\"socPct\":\"9\"}]}",
"{\"slots\":[{\"slot\":1,\"soc\":9}]}", "{\"slots\":[{\"slot\":1,\"gridCharge\":1}]}")) {
assertEquals(400, post("/api/battery/schedule", body).statusCode(), body);
}
assertTrue(batterySent.isEmpty());
}
@Test
void batteryWritesAreGuardedLikeEveryWrite() throws Exception {
for (String path : List.of("/api/battery/reserve", "/api/battery/schedule")) {
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode(), path);
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode(), path);
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode(), path);
assertEquals(405, get(path).statusCode(), path);
}
assertEquals(404, post("/api/battery/other", "{}").statusCode());
assertTrue(batterySent.isEmpty());
}
private static Forecast exampleForecast() {
Map<LocalDate, Forecast.DailyForecast> days = new TreeMap<>();
for (int i = 0; i < 5; i++) {
days.put(LocalDate.parse("2026-10-19").plusDays(i), new Forecast.DailyForecast(3, 12.0, 4.0, 0.0, 10.0, 7200.0));
}
days.put(LocalDate.parse("2026-10-24"), new Forecast.DailyForecast(3, 10.9, 3.8, 0.0, 9.0, 7151.0));
days.put(LocalDate.parse("2026-10-25"), new Forecast.DailyForecast(51, 13.2, 4.0, 0.3, 4.5, 28314.0));
days.put(LocalDate.parse("2026-10-26"), new Forecast.DailyForecast(61, 9.6, 4.1, 8.4, 2.1, 0.0));
return new Forecast(10.6, days);
}
/** Twice the forecast radiation of the same days, so the factor is 2 kWh per MJ/m². */
private static Map<LocalDate, Double> exampleMeasuredPv() {
Map<LocalDate, Double> measured = new HashMap<>();
for (int i = 0; i < 5; i++) {
measured.put(LocalDate.parse("2026-10-19").plusDays(i), 20.0);
}
return measured;
}
private static Warning exampleWarning() {
return new Warning("Silny wiatr", 2, OffsetDateTime.parse("2026-10-25T08:00:00+01:00"),
OffsetDateTime.parse("2026-10-25T18:00:00+01:00"),
"Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h.");
}
@Test
void weatherMatchesExample() throws Exception {
assertMatchesExample("/api/weather", "weather");
}
@Test
void piSensorMatchesExample()throws Exception {
assertMatchesExample("/api/pi-sensor", "pi-sensor");
}
@Test
void stalePiSensorMatchesExample() throws Exception {
Instant old = Instant.parse("2026-10-23T21:00:00Z");
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
Measurement bme = devices.getLast().measurements().get(0);
devices.set(devices.size() - 1, new DeviceSnapshot("bme280", true, old, null,
List.of(new Measurement(bme.database(), bme.measurement(), bme.fields(), old))));
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/pi-sensor", "pi-sensor-stale");
}
@Test
void historyMatchesExample() throws Exception {
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
}
@Test
void statsMatchExample() throws Exception {
assertMatchesExample("/api/stats?range=7d", "stats");
}
@Test
void statsNeedAKnownRange() throws Exception {
assertEquals(400, get("/api/stats").statusCode());
assertEquals(400, get("/api/stats?range=24h").statusCode());
assertEquals(200, get("/api/stats?range=30d").statusCode());
}
@Test
void statsAre502WhenInfluxIsDown() throws Exception {
historyDown = true;
assertEquals(502, get("/api/stats?range=7d").statusCode());
}
@Test
void historyRangeDefaultsTo24Hours() throws Exception {
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
}
@Test
void auditMatchesExample() throws Exception {
assertMatchesExample("/api/audit?range=7d", "audit");
}
@Test
void auditRangeDefaultsAndIsChecked() throws Exception {
assertEquals(200, get("/api/audit").statusCode());
assertEquals(400, get("/api/audit?range=90d").statusCode());
}
@Test
void auditInfluxFailureIs502() throws Exception {
auditDown = true;
assertEquals(502, get("/api/audit?range=7d").statusCode());
}
@Test
void unreachableGatewayIs503WithJsonError() throws Exception {
gatewayDown = true;
@@ -317,6 +630,52 @@ class ApiServerTest {
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
}
@Test
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
assertEquals(200, response.statusCode());
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
assertEquals(List.of("cwu_economy by anna"), restored);
}
@Test
void restoreStatusFollowsTheGatewaysVerdict() throws Exception {
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
Outcome.FAILED, 502);
for (var entry : expected.entrySet()) {
restoreOutcome = entry.getKey();
assertEquals(entry.getValue(), post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
}
}
@Test
void restoreWithTheGatewayDownIs503() throws Exception {
commandsDown = true;
assertEquals(503, post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
}
@Test
void restoreIsGuardedLikeEveryWrite() throws Exception {
String path = "/api/setpoints/cwu_economy/restore";
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
assertEquals(405, get(path).statusCode());
assertTrue(restored.isEmpty());
}
@Test
void otherPathsUnderSetpointsAre404() throws Exception {
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
"/api/setpoints/cwu_economy/restore/x", "/api/setpoints/CWU/restore", "/api/setpoints/a-b/restore")) {
assertEquals(404, post(path, "{}").statusCode(), path);
}
assertTrue(restored.isEmpty());
}
@Test
void rejectedIs422AndMatchesExample() throws Exception {
verdict = Status.REJECTED;
@@ -359,7 +718,7 @@ class ApiServerTest {
@Test
void presetResultsMatchExample() throws Exception {
HttpResponse<String> response = post("/api/presets/away", "");
HttpResponse<String> response = post("/api/presets/away", awayRequest());
assertEquals(200, response.statusCode());
JsonNode body = JSON.readTree(response.body());
@@ -372,12 +731,66 @@ class ApiServerTest {
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
answersBeforeDrop = 1;
HttpResponse<String> response = post("/api/presets/away", "{}");
HttpResponse<String> response = post("/api/presets/away", awayRequest());
assertEquals(502, response.statusCode());
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
}
@Test
void awayPresetSendsTheChosenEndHeldInEachChange() throws Exception {
post("/api/presets/away", awayRequest());
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
assertEquals(List.of(new Command("cwu_comfort", 10, null, end, "phone"),
new Command("co_comfort", 15, null, end, "phone")), sent);
}
@Test
void awayWithOwnValuesSendsThem() throws Exception {
var response = post("/api/presets/away", example("preset-away-values-request").toString());
assertEquals(200, response.statusCode());
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
assertEquals(List.of(new Command("cwu_comfort", 12, null, end, "phone"),
new Command("co_comfort", 16, null, end, "phone")), sent);
}
@Test
void ownValuesForOtherSetpointsOrOutsideTheLimitsAre400AndSendNothing() throws Exception {
String until = "\"until\":\"2026-11-02T18:00:00Z\"";
for (String values : List.of("{\"cwu_economy\":6}", "{\"co_comfort\":4}", "{\"co_comfort\":26}",
"{\"co_comfort\":null}", "{\"co_comfort\":15.5}", "{\"co_comfort\":\"15\"}", "[]")) {
assertEquals(400, post("/api/presets/away", "{" + until + ",\"values\":" + values + "}").statusCode(),
values);
}
assertTrue(sent.isEmpty());
}
@Test
void ownValueThatDoesNotLowerAHeldSetpointIs422() throws Exception {
var body = "{\"until\":\"2026-11-02T18:00:00Z\",\"values\":{\"co_comfort\":19}}";
assertEquals(422, post("/api/presets/away", body).statusCode());
assertTrue(sent.isEmpty());
}
@Test
void badOrMissingEndTimesAre400AndSendNothing() throws Exception {
for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}",
"{\"until\":\"2026-11-02T18:00:00\"}", "not json", "{\"until\":\"2026-11-02T18:00:00Z\",\"x\":1}")) {
assertEquals(400, post("/api/presets/away", body).statusCode(), body);
}
assertEquals(400, post("/api/presets/cwu-now", awayRequest()).statusCode());
assertTrue(sent.isEmpty());
}
@Test
void presetWithoutAnEndStillTakesNoBodyOrAnEmptyObject() throws Exception {
assertEquals(200, post("/api/presets/cwu-now", "").statusCode());
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
}
@Test
void unknownPresetIs404() throws Exception {
assertEquals(404, post("/api/presets/nope", "").statusCode());
@@ -388,7 +801,7 @@ class ApiServerTest {
void presetOutsideLimitsIs422() throws Exception {
verdict = Status.REJECTED;
assertEquals(422, post("/api/presets/away", "").statusCode());
assertEquals(422, post("/api/presets/away", awayRequest()).statusCode());
}
@Test
@@ -397,7 +810,7 @@ class ApiServerTest {
assertEquals(503, get("/api/setpoints").statusCode());
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
assertEquals(503, post("/api/presets/away", "").statusCode());
assertEquals(503, post("/api/presets/away", awayRequest()).statusCode());
}
@Test
@@ -0,0 +1,105 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import home.backend.application.AuditRepository.Row;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InfluxAuditRepositoryTest {
private static final Instant FROM = Instant.parse("2026-10-17T12:00:00Z");
private static final Instant TO = Instant.parse("2026-10-24T12:00:00Z");
private HttpServer server;
private final List<String> queries = Collections.synchronizedList(new ArrayList<>());
private String body;
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/query", exchange -> {
queries.add(URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
}
@AfterEach
void stopServer() {
server.stop(0);
}
@Test
void readsHeatPumpRowsFromTheLegacyDatabase() throws IOException {
body = fixture("influx-audit-setpoints.json");
List<Row> rows = repository().heatPump(FROM, TO);
assertEquals(2, rows.size());
assertEquals(new Row(Instant.ofEpochMilli(1761292800000L), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
rows.get(0));
assertNull(rows.get(1).oldValue());
assertEquals("co_comfort", rows.get(1).target());
assertTrue(queries.getFirst().startsWith("db=Dom_Pompa&epoch=ms&q=SELECT * FROM \"setpoint_audit\" "
+ "WHERE time >= " + FROM.toEpochMilli() + "ms AND time < " + TO.toEpochMilli() + "ms"),
queries.getFirst());
}
@Test
void readsBatteryRowsByRegisterTag() throws IOException {
body = fixture("influx-audit-battery.json");
List<Row> rows = repository().battery(FROM, TO);
assertEquals(List.of("reserve", "174"), rows.stream().map(Row::target).toList());
assertEquals(60.0, rows.getFirst().newValue());
assertTrue(queries.getFirst().startsWith("db=electricity&"), queries.getFirst());
assertTrue(queries.getFirst().contains("\"deye_settings_audit\""), queries.getFirst());
}
@Test
void noRowsIsAnEmptyList() throws IOException {
body = fixture("influx-query-empty.json");
assertTrue(repository().battery(FROM, TO).isEmpty());
}
@Test
void anInfluxErrorIsAnIOException() throws IOException {
body = fixture("influx-query-error.json");
assertThrows(IOException.class, () -> repository().heatPump(FROM, TO));
}
private InfluxAuditRepository repository() {
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
return new InfluxAuditRepository(new InfluxSettings(base, Duration.ofSeconds(5)));
}
private static String fixture(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/influx", name));
}
}
@@ -1,6 +1,7 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import org.junit.jupiter.api.AfterEach;
@@ -208,6 +209,35 @@ class InfluxHistoryRepositoryTest {
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
}
@Test
void readsRawHeatPumpPollsFromTheLegacyDatabase() throws IOException {
defaultBody = fixture("influx-query-heat-pump.json");
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
assertEquals("Dom_Pompa", requests.get(0).get("db"));
assertEquals("SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
requests.get(0).get("q"));
assertEquals(3, samples.size());
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 0.0, 8.5, 43.9),
samples.get(1));
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null),
samples.get(2));
}
@Test
void noHeatPumpPollsIsAnEmptyList() throws IOException {
assertTrue(repository(TIMEOUT).samples(FROM, TO).isEmpty());
}
@Test
void heatPumpQueryErrorIsAnIoException() throws IOException {
defaultBody = fixture("influx-query-error.json");
assertThrows(IOException.class, () -> repository(TIMEOUT).samples(FROM, TO));
}
@Test
void rejectsAnEmptyRange() {
assertThrows(IllegalArgumentException.class,
@@ -0,0 +1,89 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InfluxPvEnergyTest {
private static final LocalDate FROM = LocalDate.parse("2026-10-19");
private static final LocalDate TO = LocalDate.parse("2026-10-24");
private HttpServer server;
private InfluxPvEnergy pv;
private final List<String> queries = new ArrayList<>();
private int status = 200;
/** Day buckets start at Warsaw midnight: 2026-10-19 is 2026-10-18T22:00Z. */
private String deye = rows("1792360800000", 22.5, "1792447200000", 8.0);
private String fronius = rows("1792360800000", 4400.0, "1792533600000", 100.0);
@BeforeEach
void start() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/query", exchange -> {
String query = URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8);
queries.add(query);
byte[] bytes = (query.contains("\"deye\"") ? deye : fronius).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
var settings = new InfluxSettings(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
Duration.ofSeconds(2));
pv = new InfluxPvEnergy(settings);
}
@AfterEach
void stop() {
server.stop(0);
}
private static String rows(String t1, double v1, String t2, double v2) {
return "{\"results\":[{\"statement_id\":0,\"series\":[{\"name\":\"m\",\"columns\":[\"time\",\"max\"],"
+ "\"values\":[[" + t1 + "," + v1 + "],[" + t2 + "," + v2 + "]]}]}]}";
}
@Test
void addsTheDeyeAndFroniusDayCountersPerLocalDay() throws IOException {
Map<LocalDate, Double> days = pv.dailyKwh(FROM, TO);
assertEquals(Map.of(FROM, 22.5 + 4.4), days);
}
@Test
void asksForTheDailyMaximumOfEachCounterInWarsawDays() throws IOException {
pv.dailyKwh(FROM, TO);
assertEquals(2, queries.size());
String deyeQuery = queries.stream().filter(q -> q.contains("\"deye\"")).findFirst().orElseThrow();
assertTrue(deyeQuery.contains("db=electricity"), deyeQuery);
assertTrue(deyeQuery.contains("SELECT max(\"pv_today_kwh\") FROM \"deye\" WHERE time >= 1792360800000ms"
+ " AND time < 1792792800000ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')"), deyeQuery);
assertTrue(queries.stream().anyMatch(q -> q.contains("max(\"energy_day_wh\") FROM \"fronius\"")), queries.toString());
}
@Test
void failureIsAnIoError() {
status = 500;
assertThrows(IOException.class, () -> pv.dailyKwh(FROM, TO));
}
}
@@ -0,0 +1,185 @@
package home.backend.adapters.weather;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.Forecast;
import home.backend.domain.WeatherView.Warning;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** The two clients against a local server that answers with responses recorded from the real services. */
class WeatherClientsTest {
private static final WeatherSettings PLACE = WeatherSettings.fromEnvironment(Map.of());
private static final Duration TIMEOUT = Duration.ofSeconds(2);
private HttpServer server;
private URI base;
private final List<String> requests = new ArrayList<>();
private int status = 200;
private String body = "";
@BeforeEach
void start() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/", exchange -> {
requests.add(exchange.getRequestURI().toString());
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
}
@AfterEach
void stop() {
server.stop(0);
}
private static String fixture(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/weather", name));
}
@Test
void readsTheOpenMeteoForecastForTheHouse() throws IOException {
body = fixture("open-meteo.json");
Forecast forecast = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast();
assertEquals(10.6, forecast.temperature());
assertEquals(17, forecast.days().size());
var today = forecast.days().get(LocalDate.parse("2026-10-24"));
assertEquals(3, today.weatherCode());
assertEquals(10.9, today.tMax());
assertEquals(3.8, today.tMin());
assertEquals(6.88, today.radiationMj());
assertEquals(7151.32, today.sunshineSeconds());
assertEquals(8.4, forecast.days().get(LocalDate.parse("2026-10-26")).precipitationMm());
String request = requests.getFirst();
for (String part : List.of("latitude=49.684", "longitude=21.77", "timezone=Europe%2FWarsaw",
"past_days=14", "shortwave_radiation_sum", "sunshine_duration")) {
assertEquals(true, request.contains(part), part + " in " + request);
}
}
@Test
void aMissingValueInTheForecastIsNull() throws IOException {
body = """
{"daily":{"time":["2026-10-24"],"weather_code":[null],"temperature_2m_max":[1.5]}}""";
var day = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast().days().get(LocalDate.parse("2026-10-24"));
assertNull(day.weatherCode());
assertEquals(1.5, day.tMax());
assertNull(day.tMin());
assertNull(day.radiationMj());
}
@Test
void anOpenMeteoFailureIsAnIoError() {
body = "{\"error\":true}";
status = 500;
var client = new OpenMeteoClient(base, PLACE, TIMEOUT);
assertThrows(IOException.class, client::forecast);
status = 200;
body = "not json";
assertThrows(IOException.class, client::forecast);
body = "{\"daily\":{}}";
assertThrows(IOException.class, client::forecast);
}
@Test
void keepsWarningsThatCoverTheCityOrThePowiatAndMapsThem() throws IOException {
body = fixture("imgw-warnings.json");
List<Warning> warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings();
assertEquals("/api/data/warningsmeteo", requests.getFirst());
assertEquals(2, warnings.size());
Warning city = warnings.get(0);
assertEquals("Silny wiatr", city.event());
assertEquals(2, city.level());
assertEquals(OffsetDateTime.parse("2026-10-25T08:00:00+01:00"), city.from());
assertEquals(OffsetDateTime.parse("2026-10-25T18:00:00+01:00"), city.to());
assertEquals("Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h, "
+ "w porywach do 90 km/h, z południa.", city.text());
assertEquals(1, warnings.get(1).level());
}
@Test
void otherAreasAreIgnoredAndTerytIsConfigurable() throws IOException {
body = fixture("imgw-warnings.json");
var cityOnly = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("1862"));
var elsewhere = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("2804"));
assertEquals(1, new ImgwClient(base, cityOnly, TIMEOUT).warnings().size());
assertEquals(1, new ImgwClient(base, elsewhere, TIMEOUT).warnings().size());
assertEquals(0, new ImgwClient(base, new WeatherSettings(0, 0, List.of("0000")), TIMEOUT).warnings().size());
}
@Test
void aMalformedWarningIsSkippedNotFatal() throws IOException {
body = """
[{"nazwa_zdarzenia":"Burze","stopien":"x","obowiazuje_od":"2026-10-25 08:00:00",
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"a","teryt":["1862"]},
{"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"soon",
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"b","teryt":["1862"]},
{"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"2026-10-25 08:00:00",
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"c","teryt":["1862"]}]""";
List<Warning> warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings();
assertEquals(1, warnings.size());
assertEquals("c", warnings.getFirst().text());
}
@Test
void anImgwFailureIsAnIoError() {
body = "{}";
var client = new ImgwClient(base, PLACE, TIMEOUT);
assertThrows(IOException.class, client::warnings);
status = 503;
assertThrows(IOException.class, client::warnings);
}
@Test
void settingsDefaultToKrosnoAndRejectNonsense() {
assertEquals(49.684, PLACE.latitude());
assertEquals(21.770, PLACE.longitude());
assertEquals(List.of("1862", "1807"), PLACE.teryt());
var custom = WeatherSettings.fromEnvironment(
Map.of("WEATHER_LAT", "50.1", "WEATHER_LON", "-20", "WEATHER_TERYT", "1465, 1401"));
assertEquals(50.1, custom.latitude());
assertEquals(-20.0, custom.longitude());
assertEquals(List.of("1465", "1401"), custom.teryt());
assertThrows(IllegalArgumentException.class,
() -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LAT", "north")));
assertThrows(IllegalArgumentException.class,
() -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LON", "200")));
}
}
@@ -0,0 +1,108 @@
package home.backend.application;
import home.backend.application.AuditRepository.Row;
import home.backend.domain.AuditEntry;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class AuditServiceTest {
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
private List<Row> heatPump = List.of();
private List<Row> battery = List.of();
private final List<Instant> asked = new ArrayList<>();
private final AuditRepository repository = new AuditRepository() {
@Override
public List<Row> heatPump(Instant from, Instant to) {
asked.add(from);
return heatPump;
}
@Override
public List<Row> battery(Instant from, Instant to) throws IOException {
if (battery == null) {
throw new IOException("influx down");
}
return battery;
}
};
private final AuditService service = new AuditService(repository, CLOCK);
private static Row row(String time, String target, String by, Double old, Double value) {
return new Row(Instant.parse(time), target, by, "APPLIED", old, value);
}
@Test
void mergesBothSourcesNewestFirst() throws IOException {
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "a", 8.0, 45.0),
row("2026-10-22T08:00:00Z", "co_comfort", "a", 19.0, 20.0));
battery = List.of(row("2026-10-23T08:00:00Z", "reserve", "b", 50.0, 60.0));
var entries = service.audit(null).entries();
assertEquals(List.of("CWU eko", "Rezerwa baterii", "CO komfort"),
entries.stream().map(AuditEntry::what).toList());
assertEquals(List.of("heat_pump", "battery", "heat_pump"),
entries.stream().map(AuditEntry::source).toList());
}
@Test
void namesTheDeyeRegisters() throws IOException {
battery = List.of("146", "148", "153", "166", "171", "172", "177", "reserve", "999").stream()
.map(register -> row("2026-10-24T08:00:00Z", register, "a", 0.0, 1.0)).toList();
var entries = service.audit("24h").entries();
assertEquals(List.of("Harmonogram baterii/0/1", "Przedział 1: start/HHMM", "Przedział 6: start/HHMM",
"Przedział 1: poziom/%", "Przedział 6: poziom/%", "Przedział 1: ładowanie z sieci/0/1",
"Przedział 6: ładowanie z sieci/0/1", "Rezerwa baterii/%", "Rejestr 999/"),
entries.stream().map(e -> e.what() + "/" + e.unit()).toList());
}
@Test
void showsARestoreAsAutomaticAndKeepsMissingValuesNull() throws IOException {
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "restore", null, 8.4));
var entry = service.audit("7d").entries().getFirst();
assertEquals("automatycznie (przywrócenie)", entry.who());
assertNull(entry.oldValue());
assertEquals(8, entry.newValue());
assertEquals("°C", entry.unit());
}
@Test
void defaultsToSevenDaysAndAllowsUpToThirty() throws IOException {
service.audit(null);
service.audit("30d");
assertEquals(List.of(NOW.minus(Duration.ofDays(7)), NOW.minus(Duration.ofDays(30))), asked);
}
@Test
void rejectsAnUnknownRange() {
assertThrows(IllegalArgumentException.class, () -> service.audit("90d"));
assertThrows(IllegalArgumentException.class, () -> service.audit(""));
}
@Test
void failsWhenOneSourceFails() {
battery = null;
assertThrows(IOException.class, () -> service.audit("7d"));
}
}
@@ -2,6 +2,7 @@ package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import org.junit.jupiter.api.Test;
@@ -26,6 +27,11 @@ class LeaseRenewerTest {
throw new UnsupportedOperationException();
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) {
throw new UnsupportedOperationException();
}
@Override
public SetpointState setpoints() {
throw new UnsupportedOperationException();
@@ -1,5 +1,8 @@
package home.backend.application;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.EnergyDetails;
import home.backend.domain.EnergyDetails.Device;
import home.backend.domain.EnergyDetails.Group;
@@ -13,6 +16,7 @@ import home.backend.domain.HealthView;
import home.backend.domain.HeatPumpView;
import home.backend.domain.HeatPumpView.State;
import home.backend.domain.HistoryView;
import home.backend.domain.PiSensorView;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import org.junit.jupiter.api.Test;
@@ -22,6 +26,7 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -170,6 +175,189 @@ class ServicesTest {
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
}
private static Map<String, Double> settings(double touEnabled) {
Map<String, Double> fields = new HashMap<>(Map.of("battery_capacity_ah", 280.0,
"tou_enabled", touEnabled, "grid_charge_enabled", 0.0));
double[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int n = 1; n <= 6; n++) {
fields.putAll(Map.of("prog" + n + "_start_hhmm", starts[n - 1], "prog" + n + "_power_w", 8000.0,
"prog" + n + "_soc_pct", 60.0, "prog" + n + "_grid_charge", n == 2 ? 1.0 : 0.0));
}
return fields;
}
private static BatteryView battery(DeviceSnapshot... devices) throws IOException {
return new BatteryService(gateway(devices), new FakeBattery(), CLOCK).current();
}
private static final class FakeBattery implements GatewayBattery {
static final List<String> SENT = new java.util.ArrayList<>();
final boolean enabled;
FakeBattery() {
this(true);
}
FakeBattery(boolean enabled) {
this.enabled = enabled;
}
@Override
public BatteryView.Reserve reserve() throws IOException {
if (!enabled) {
throw new IOException("gateway /battery/reserve answered 404");
}
return new BatteryView.Reserve(50, 30);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) {
SENT.add("schedule by " + requestedBy);
return new BatteryResult(BatteryResult.Status.APPLIED, "applied", Map.of());
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) {
SENT.add("reserve " + pct + " by " + requestedBy);
return new BatteryResult(BatteryResult.Status.APPLIED, "applied", Map.of());
}
}
@Test
void batteryShowsTheReserveAndIsEditableWhenTheGatewayAcceptsChanges() throws IOException {
BatteryView view = battery();
assertEquals(new BatteryView.Reserve(50, 30), view.reserve());
assertTrue(view.editable());
}
@Test
void batteryIsNotEditableWithoutGatewayCommandsButStillShowsTheLiveState() throws IOException {
BatteryView view = new BatteryService(gateway(device("deye", "home", "deye",
Map.of("battery_soc_pct", 80.0))), new FakeBattery(false), CLOCK).current();
assertNull(view.reserve());
assertFalse(view.editable());
assertEquals(80.0, view.socPct());
}
@Test
void batteryChangesAreHandedToTheGatewayWithTheUser() throws IOException {
var service = new BatteryService(gateway(), new FakeBattery(), CLOCK);
assertEquals(BatteryResult.Status.APPLIED, service.changeReserve(60, "anna").status());
assertEquals(BatteryResult.Status.APPLIED,
service.changeSchedule(new BatteryChange(true, null), "ola").status());
assertEquals(List.of("reserve 60 by anna", "schedule by ola"), FakeBattery.SENT);
}
private static DeviceSnapshot deyeSettings(Map<String, Double> fields, Instant time) {
return new DeviceSnapshot("deye-settings", true, time, null,
List.of(new Measurement("electricity", "deye_settings", fields, time)));
}
@Test
void batteryReadsLiveValuesAndComputesCapacityAtNominalVoltage() throws IOException {
BatteryView view = battery(device("deye", "home", "deye", Map.of("battery_soc_pct", 80.0,
"battery_power_w", 1500.0, "battery_charge_today_kwh", 2.5)),
deyeSettings(settings(0), READ));
assertFalse(view.stale());
assertEquals(80.0, view.socPct());
assertEquals(1500.0, view.powerW());
assertEquals(2.5, view.today().chargeKwh());
assertEquals(14.336, view.capacityKwh(), 1e-9);
}
@Test
void batteryScheduleRunsToTheNextStartAndWrapsAroundMidnight() throws IOException {
List<BatteryView.Slot> schedule = battery(deyeSettings(settings(1), READ)).schedule();
assertEquals(6, schedule.size());
assertEquals(new BatteryView.Slot("01:00", "05:00", 8000, 60, false), schedule.get(0));
assertEquals(new BatteryView.Slot("05:00", "09:00", 8000, 60, true), schedule.get(1));
assertEquals(new BatteryView.Slot("21:00", "01:00", 8000, 60, false), schedule.get(5));
}
@Test
void batteryModeFollowsTimeOfUse() throws IOException {
BatteryView backup = battery(deyeSettings(settings(0), READ));
BatteryView scheduled = battery(deyeSettings(settings(1), READ));
assertFalse(backup.mode().touEnabled());
assertEquals("Rezerwa na awarię: bateria nie rozładowuje się na co dzień", backup.mode().description());
assertTrue(scheduled.mode().touEnabled());
assertEquals("Harmonogram: bateria pracuje według przedziałów", scheduled.mode().description());
}
@Test
void batterySettingsAreStaleAfterThirtyMinutesButLiveValuesStayCurrent() throws IOException {
var live = device("deye", "home", "deye", Map.of("battery_soc_pct", 80.0));
BatteryView fresh = battery(live, deyeSettings(settings(0), NOW.minusSeconds(1800)));
BatteryView old = battery(live, deyeSettings(settings(0), NOW.minusSeconds(1801)));
assertFalse(fresh.settingsStale());
assertEquals(6, fresh.schedule().size());
assertTrue(old.settingsStale());
assertEquals(NOW.minusSeconds(1801), old.settingsTime());
assertTrue(old.schedule().isEmpty());
assertNull(old.mode());
assertNull(old.limits());
assertNull(old.capacityKwh());
assertEquals(80.0, old.socPct());
}
@Test
void staleDeyeShowsNoLiveBatteryValues() throws IOException {
var old = new Measurement("home", "deye", Map.of("battery_soc_pct", 80.0), NOW.minusSeconds(601));
BatteryView view = battery(new DeviceSnapshot("deye", true, old.time(), null, List.of(old)),
deyeSettings(settings(0), READ));
assertTrue(view.stale());
assertNull(view.socPct());
assertNull(view.powerW());
assertFalse(view.settingsStale());
}
@Test
void batteryWithoutAnyReadingIsStaleTwice() throws IOException {
BatteryView view = battery();
assertTrue(view.stale());
assertTrue(view.settingsStale());
assertNull(view.time());
assertNull(view.settingsTime());
}
private static PiSensorView piSensor(DeviceSnapshot... devices) throws IOException {
return new PiSensorService(gateway(devices), CLOCK).current();
}
@Test
void piSensorReadsTemperatureHumidityAndPressure() throws IOException {
PiSensorView view = piSensor(device("bme280", "measurement", "MEASUREMENT",
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8)));
assertEquals(READ, view.time());
assertFalse(view.stale());
assertEquals(20.17, view.temperature());
assertEquals(45.82, view.humidity());
assertEquals(985.8, view.pressure());
}
@Test
void stalePiSensorShowsNoValues() throws IOException {
var old = new Measurement("measurement", "MEASUREMENT", Map.of("temperature", 20.0), NOW.minusSeconds(601));
PiSensorView view = piSensor(new DeviceSnapshot("bme280", true, old.time(), null, List.of(old)));
assertTrue(view.stale());
assertNull(view.temperature());
assertTrue(piSensor().stale());
assertNull(piSensor().time());
}
private static EnergyView energy(DeviceSnapshot... devices) throws IOException {
return new EnergyService(gateway(devices), CLOCK).current();
}
@@ -3,6 +3,8 @@ package home.backend.application;
import home.backend.adapters.config.PresetFile;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.RestoreResult.Outcome;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.Preset;
import home.backend.domain.Preset.Change;
@@ -16,8 +18,10 @@ import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -25,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class SetpointServiceTest {
private static final Instant RETURN = Instant.parse("2026-11-02T17:00:00Z");
private static final SetpointState STATE = new SetpointState(List.of(
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
@@ -33,6 +38,7 @@ class SetpointServiceTest {
private final List<Command> sent = new ArrayList<>();
/** What the gateway does on the next calls to apply, in order: a Status, or an IOException; then APPLIED. */
private final List<Object> script = new ArrayList<>();
private final List<String> restored = new ArrayList<>();
private boolean down;
private final GatewayCommands gateway = new GatewayCommands() {
@Override
@@ -48,6 +54,15 @@ class SetpointServiceTest {
return new CommandResult((Status) next, command.setpoint(), null);
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
if (down) {
throw new IOException("tunnel down");
}
restored.add(setpoint + " by " + requestedBy);
return new RestoreResult(Outcome.RESTORED, "restored");
}
@Override
public SetpointState setpoints() throws IOException {
if (down) {
@@ -64,6 +79,20 @@ class SetpointServiceTest {
private final List<Preset> presets = PresetFile.load(null);
private final SetpointService service = new SetpointService(gateway, presets);
@Test
void restoreAsksTheGatewayInTheUsersName() throws Exception {
assertEquals(Outcome.RESTORED, service.restore("cwu_economy", "anna").outcome());
assertEquals(List.of("cwu_economy by anna"), restored);
}
@Test
void restoreWithTheGatewayDownIsAnIoError() {
down = true;
assertThrows(IOException.class, () -> service.restore("cwu_economy", "anna"));
}
@Test
void viewJoinsGatewayStateAndPresets() throws Exception {
var view = service.current();
@@ -98,33 +127,93 @@ class SetpointServiceTest {
@Test
void boostPresetSendsOneTimedCommand() throws Exception {
var result = service.applyPreset("cwu-now", "anna").orElseThrow();
var result = service.applyPreset("cwu-now", null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.APPLIED, result.status());
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
}
@Test
void awayPresetSendsBothChangesUntilChanged() throws Exception {
var result = service.applyPreset("away", "anna").orElseThrow();
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(2, result.results().size());
assertEquals(List.of(new Command("cwu_comfort", 10, null, "anna"), new Command("co_comfort", 15, null, "anna")),
sent);
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
new Command("co_comfort", 15, null, RETURN, "anna")), sent);
}
@Test
void ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
service.applyPreset("away", RETURN, Map.of("co_comfort", 16), "anna");
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
}
@Test
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
service.applyPreset("cwu-now", null, Map.of("cwu_economy", 40), "anna");
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
}
@Test
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
assertThrows(IllegalArgumentException.class,
() -> service.applyPreset("away", RETURN, Map.of("cwu_economy", 6), "anna"));
assertTrue(sent.isEmpty());
}
@Test
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
for (int value : List.of(4, 26, 0)) {
assertThrows(IllegalArgumentException.class,
() -> service.applyPreset("away", RETURN, Map.of("co_comfort", value), "anna"));
}
assertTrue(sent.isEmpty());
}
@Test
void aHeldValueThatDoesNotLowerTheCurrentOneWritesNothing() throws Exception {
var result = service.applyPreset("away", RETURN, Map.of("co_comfort", 19), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
assertTrue(sent.isEmpty());
}
@Test
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, Map.of(), "anna"));
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, Map.of(), "anna"));
assertTrue(sent.isEmpty());
}
@Test
void aHeldPresetThatWouldNotLowerAnySetpointWritesNothing() throws Exception {
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
new Change("co_comfort", 19, null)));
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
assertTrue(result.results().getLast().message().contains("not below the current 19"));
assertTrue(sent.isEmpty());
}
@Test
void unknownPresetIsEmpty() throws Exception {
assertTrue(service.applyPreset("nope", "anna").isEmpty());
assertTrue(service.applyPreset("nope", null, Map.of(), "anna").isEmpty());
assertTrue(sent.isEmpty());
}
@Test
void presetOutsideGatewayLimitsWritesNothing() throws Exception {
var tooHot = new Preset("hot", "Za gorąco", List.of(new Change("co_comfort", 15, null),
var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null),
new Change("cwu_economy", 60, 2.0)));
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", "anna").orElseThrow();
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
@@ -134,9 +223,9 @@ class SetpointServiceTest {
@Test
void presetWithUnknownSetpointWritesNothing() throws Exception {
var odd = new Preset("odd", "Dziwny", List.of(new Change("garage_heater", 1, null)));
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", "anna").orElseThrow();
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertTrue(sent.isEmpty());
@@ -150,7 +239,7 @@ class SetpointServiceTest {
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
script.add(Status.FAILED);
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -161,7 +250,7 @@ class SetpointServiceTest {
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
@@ -171,7 +260,7 @@ class SetpointServiceTest {
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
script.add(Status.REJECTED);
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -181,7 +270,7 @@ class SetpointServiceTest {
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
@@ -191,7 +280,7 @@ class SetpointServiceTest {
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
script.add(new IOException("tunnel dropped"));
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -203,7 +292,7 @@ class SetpointServiceTest {
assertThrows(IOException.class, service::current);
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, Map.of(), "anna"));
assertTrue(sent.isEmpty());
}
}
@@ -0,0 +1,214 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.StatsView;
import home.backend.domain.StatsView.Day;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class StatsServiceTest {
/** Monday 2026-10-26 11:00 in Warsaw; the week before it holds the change to winter time. */
private static final Instant NOW = Instant.parse("2026-10-26T10:00:00Z");
private Instant askedFrom;
private Instant askedTo;
private StatsView stats(String range, Instant now, HeatPumpSample... samples) throws IOException {
HeatPumpSamples repository = (from, to) -> {
askedFrom = from;
askedTo = to;
return List.of(samples);
};
return new StatsService(repository, Clock.fixed(now, ZoneOffset.UTC)).stats(range);
}
private static HeatPumpSample compressor(String time, double stage) {
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, 0.0, null, null);
}
private static Day day(StatsView view, String date) {
return view.days().stream().filter(d -> d.date().equals(LocalDate.parse(date))).findFirst().orElseThrow();
}
@Test
void asksForWholeLocalDaysUpToNow() throws IOException {
stats("7d", NOW);
assertEquals(Instant.parse("2026-10-19T22:00:00Z"), askedFrom);
assertEquals(NOW, askedTo);
}
@Test
void listsOneDayPerLocalDateOldestFirst() throws IOException {
StatsView view = stats("30d", NOW);
assertEquals(30, view.days().size());
assertEquals(LocalDate.parse("2026-09-27"), view.days().getFirst().date());
assertEquals(LocalDate.parse("2026-10-26"), view.days().getLast().date());
}
@Test
void noDataGivesEmptyDaysAndZeroTotals() throws IOException {
StatsView view = stats("7d", NOW);
assertEquals(7, view.days().size());
for (Day d : view.days()) {
assertNull(d.compressorHours());
assertNull(d.compressorStarts());
assertNull(d.avgOutdoorTemp());
}
assertEquals(0.0, view.totals().compressorHours());
assertEquals(0, view.totals().compressorStarts());
assertNull(view.totals().avgOutdoorTemp());
assertNull(view.totals().minCwuTemp());
}
@Test
void aGapIsCappedAtTenMinutesPerPoll() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-23T10:00:00Z", 100),
compressor("2026-10-23T10:05:00Z", 100),
compressor("2026-10-23T11:00:00Z", 0));
assertEquals(0.25, day(view, "2026-10-23").compressorHours());
}
@Test
void theLastPollHoldsUntilNowAtMostTenMinutes() throws IOException {
StatsView recent = stats("7d", NOW, compressor("2026-10-26T09:58:00Z", 50));
StatsView old = stats("7d", NOW, compressor("2026-10-26T08:00:00Z", 50));
assertEquals(0.03, day(recent, "2026-10-26").compressorHours());
assertEquals(0.17, day(old, "2026-10-26").compressorHours());
}
@Test
void countsOnlyTransitionsFromOffToOn() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-23T10:00:00Z", 100),
compressor("2026-10-23T10:05:00Z", 100),
compressor("2026-10-23T10:10:00Z", 0),
compressor("2026-10-23T10:15:00Z", 0),
compressor("2026-10-23T10:20:00Z", 50),
compressor("2026-10-23T10:25:00Z", 100),
compressor("2026-10-23T10:30:00Z", 0),
compressor("2026-10-23T10:35:00Z", 100));
assertEquals(2, day(view, "2026-10-23").compressorStarts());
assertEquals(2, view.totals().compressorStarts());
}
@Test
void aStartAtMidnightBelongsToTheNewDay() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-24T21:55:00Z", 0),
compressor("2026-10-24T22:00:00Z", 100));
assertEquals(0, day(view, "2026-10-24").compressorStarts());
assertEquals(1, day(view, "2026-10-25").compressorStarts());
}
@Test
void splitsAStretchAtLocalMidnightInSummerTime() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-24T21:58:00Z", 100),
compressor("2026-10-24T22:03:00Z", 0));
assertEquals(0.03, day(view, "2026-10-24").compressorHours());
assertEquals(0.05, day(view, "2026-10-25").compressorHours());
}
@Test
void splitsAStretchAtLocalMidnightInWinterTime() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-25T22:58:00Z", 100),
compressor("2026-10-25T23:03:00Z", 0));
assertEquals(0.03, day(view, "2026-10-25").compressorHours());
assertEquals(0.05, day(view, "2026-10-26").compressorHours());
}
@Test
void theDayOfTheClockChangeLastsTwentyFiveHours() throws IOException {
List<HeatPumpSample> samples = new ArrayList<>();
Instant at = Instant.parse("2026-10-24T22:00:00Z");
while (at.isBefore(Instant.parse("2026-10-25T23:00:00Z"))) {
samples.add(compressor(at.toString(), 100));
at = at.plusSeconds(300);
}
StatsView view = stats("7d", NOW, samples.toArray(HeatPumpSample[]::new));
assertEquals(25.0, day(view, "2026-10-25").compressorHours());
assertNull(day(view, "2026-10-26").compressorHours());
}
@Test
void sumsEachOutputSeparately() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 100.0, 0.0, 0.0, 10.0, 40.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 100.0, 12.0, 38.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, 0.0, null, null));
Day d = day(view, "2026-10-23");
assertEquals(0.17, d.compressorHours());
assertEquals(0.08, d.cwuHours());
assertEquals(0.08, d.coHours());
assertEquals(0.08, d.heaterHours());
assertEquals(11.0, d.avgOutdoorTemp());
assertEquals(38.0, d.minCwuTemp());
assertEquals(40.0, d.maxCwuTemp());
}
@Test
void totalsCoverTheWholeRangeWithTheTemperatureOfAllPolls() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-22T10:00:00Z"), 100.0, 0.0, 0.0, 0.0, 10.0, 30.0),
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 10.0, 31.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 100.0, 4.0, 45.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 4.0, 44.0));
assertEquals(0.17, view.totals().compressorHours());
assertEquals(0.08, view.totals().heaterHours());
assertEquals(7.0, view.totals().avgOutdoorTemp());
assertEquals(30.0, view.totals().minCwuTemp());
assertEquals(45.0, view.totals().maxCwuTemp());
}
@Test
void aMissingFieldCountsAsOffAndUnknownStateIsNoStart() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), null, null, null, null, null, null),
compressor("2026-10-23T10:05:00Z", 100));
assertEquals(0, day(view, "2026-10-23").compressorStarts());
assertNull(day(view, "2026-10-23").avgOutdoorTemp());
}
@Test
void rejectsAnUnknownRange() {
assertThrows(IllegalArgumentException.class, () -> stats("24h", NOW));
assertThrows(IllegalArgumentException.class, () -> stats(null, NOW));
}
@Test
void propagatesRepositoryFailure() {
StatsService service = new StatsService((from, to) -> {
throw new IOException("influx down");
}, Clock.fixed(NOW, ZoneOffset.UTC));
assertThrows(IOException.class, () -> service.stats("7d"));
}
}
@@ -0,0 +1,231 @@
package home.backend.application;
import home.backend.domain.Forecast;
import home.backend.domain.Forecast.DailyForecast;
import home.backend.domain.WeatherView;
import home.backend.domain.WeatherView.Warning;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WeatherServiceTest {
private static final Instant START = Instant.parse("2026-10-24T10:00:00Z");
private static final LocalDate TODAY = LocalDate.parse("2026-10-24");
private Instant now = START;
private final Clock clock = new Clock() {
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return Clock.fixed(now, zone);
}
@Override
public Instant instant() {
return now;
}
};
private final AtomicInteger forecastCalls = new AtomicInteger();
private final AtomicInteger warningCalls = new AtomicInteger();
private boolean forecastDown;
private boolean warningsDown;
private boolean influxDown;
private int measuredDays = 5;
private List<Warning> warned = List.of(warning(START.plus(Duration.ofHours(8))));
private final WeatherService service = new WeatherService(
() -> {
forecastCalls.incrementAndGet();
if (forecastDown) {
throw new IOException("open-meteo down");
}
return forecast();
},
() -> {
warningCalls.incrementAndGet();
if (warningsDown) {
throw new IOException("imgw down");
}
return warned;
},
(from, to) -> {
if (influxDown) {
throw new IOException("influx down");
}
assertEquals(TODAY.minusDays(14), from);
assertEquals(TODAY, to);
Map<LocalDate, Double> measured = new HashMap<>();
for (int i = 1; i <= measuredDays; i++) {
measured.put(TODAY.minusDays(i), 15.0);
}
return measured;
},
clock);
private static Forecast forecast() {
Map<LocalDate, DailyForecast> days = new TreeMap<>();
for (int i = 1; i <= 14; i++) {
days.put(TODAY.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0));
}
days.put(TODAY, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0));
days.put(TODAY.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null));
days.put(TODAY.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0));
return new Forecast(8.5, days);
}
private static Warning warning(Instant until) {
return new Warning("Burza", 2, OffsetDateTime.ofInstant(START, ZoneOffset.UTC),
OffsetDateTime.ofInstant(until, ZoneOffset.UTC), "text");
}
@Test
void shapesThreeDaysAndEstimatesPvFromTheFactor() throws IOException {
WeatherView view = service.current();
assertEquals(START, view.time());
assertEquals(8.5, view.now().temperature());
assertEquals(3.0, view.pvFactor(), 1e-9);
assertEquals(TODAY, view.today().date());
assertEquals("Bezchmurnie", view.today().description());
assertEquals(12.34, view.today().tMax());
assertEquals(1.5, view.today().sunshineHours());
assertEquals(18.0, view.today().pvForecastKwh());
assertEquals(TODAY.plusDays(2), view.dayAfter().date());
assertEquals("Deszcz", view.tomorrow().description());
assertEquals(5.5, view.tomorrow().precipitationMm());
assertNull(view.tomorrow().pvForecastKwh());
assertEquals(3.0, view.dayAfter().pvForecastKwh());
assertEquals("Brak danych", view.dayAfter().description());
assertFalse(view.stale());
}
@Test
void usesTheWarsawDateNearMidnight() throws IOException {
now = Instant.parse("2026-10-23T22:30:00Z");
assertEquals(TODAY, service.current().today().date());
}
@Test
void fewerThanFiveMeasuredDaysGiveNoEstimate() throws IOException {
measuredDays = 4;
WeatherView view = service.current();
assertNull(view.pvFactor());
assertNull(view.today().pvForecastKwh());
assertEquals(12.34, view.today().tMax());
}
@Test
void anUnreachableInfluxGivesNoEstimateButStillTheWeather() throws IOException {
influxDown = true;
WeatherView view = service.current();
assertNull(view.pvFactor());
assertEquals(0, view.today().weatherCode());
assertFalse(view.stale());
}
@Test
void keepsTheForecastFor30MinutesAndWarningsFor10() throws IOException {
service.current();
now = START.plus(Duration.ofMinutes(9));
service.current();
assertEquals(1, warningCalls.get());
now = START.plus(Duration.ofMinutes(10));
service.current();
assertEquals(2, warningCalls.get());
assertEquals(1, forecastCalls.get());
now = START.plus(Duration.ofMinutes(30));
service.current();
assertEquals(2, forecastCalls.get());
}
@Test
void servesTheOlderForecastMarkedStaleWhenTheRefreshFails() throws IOException {
service.current();
forecastDown = true;
now = START.plus(Duration.ofMinutes(31));
WeatherView view = service.current();
assertTrue(view.stale());
assertEquals(8.5, view.now().temperature());
assertEquals(3.0, view.pvFactor(), 1e-9);
}
@Test
void servesTheOlderWarningsMarkedStaleWhenTheRefreshFails() throws IOException {
service.current();
warningsDown = true;
now = START.plus(Duration.ofMinutes(11));
WeatherView view = service.current();
assertTrue(view.stale());
assertEquals(1, view.warnings().size());
}
@Test
void withoutAnyForecastItFails() {
forecastDown = true;
assertThrows(IOException.class, service::current);
}
@Test
void withoutAnyWarningsItStillAnswersMarkedStale() throws IOException {
warningsDown = true;
WeatherView view = service.current();
assertTrue(view.warnings().isEmpty());
assertTrue(view.stale());
assertEquals(12.34, view.today().tMax());
}
@Test
void dropsWarningsThatHaveEnded() throws IOException {
warned = List.of(warning(START.plus(Duration.ofMinutes(5))), warning(START.plus(Duration.ofHours(8))));
assertEquals(2, service.current().warnings().size());
now = START.plus(Duration.ofMinutes(6));
assertEquals(1, service.current().warnings().size());
}
@Test
void aForecastWithoutTodayFails() {
var short_ = new WeatherService(() -> new Forecast(null, Map.of()), List::of, (from, to) -> Map.of(), clock);
assertThrows(IOException.class, short_::current);
}
}
@@ -0,0 +1,84 @@
package home.backend.domain;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class WeatherDomainTest {
private static final LocalDate DAY = LocalDate.parse("2026-10-10");
@Test
void describesWmoCodesInPolish() {
assertEquals("Bezchmurnie", WeatherCodes.describe(0));
assertEquals("Pochmurno", WeatherCodes.describe(3));
assertEquals("Mgła", WeatherCodes.describe(45));
assertEquals("Mżawka", WeatherCodes.describe(53));
assertEquals("Deszcz", WeatherCodes.describe(63));
assertEquals("Śnieg", WeatherCodes.describe(73));
assertEquals("Przelotny deszcz", WeatherCodes.describe(81));
assertEquals("Burza", WeatherCodes.describe(95));
assertEquals("Burza z gradem", WeatherCodes.describe(99));
}
@Test
void anUnknownOrMissingCodeIsNoData() {
assertEquals("Brak danych", WeatherCodes.describe(42));
assertEquals("Brak danych", WeatherCodes.describe(null));
}
@Test
void calibratesAsTotalMeasuredOverTotalRadiation() {
var measured = new HashMap<LocalDate, Double>();
var radiation = new HashMap<LocalDate, Double>();
for (int i = 0; i < 5; i++) {
measured.put(DAY.plusDays(i), 10.0 + i);
radiation.put(DAY.plusDays(i), 5.0 + i);
}
// 60 kWh over 35 MJ/m², not the mean of the daily ratios.
assertEquals(60.0 / 35.0, PvCalibration.factor(measured, radiation), 1e-9);
}
@Test
void daysMissingEitherValueDoNotCount() {
Map<LocalDate, Double> measured = new HashMap<>();
Map<LocalDate, Double> radiation = new HashMap<>();
for (int i = 0; i < 5; i++) {
measured.put(DAY.plusDays(i), 10.0);
radiation.put(DAY.plusDays(i), 5.0);
}
measured.put(DAY.plusDays(9), 1000.0);
radiation.put(DAY.plusDays(8), 1000.0);
assertEquals(2.0, PvCalibration.factor(measured, radiation), 1e-9);
}
@Test
void fewerThanFiveDaysGiveNoFactor() {
Map<LocalDate, Double> measured = new HashMap<>();
Map<LocalDate, Double> radiation = new HashMap<>();
for (int i = 0; i < 4; i++) {
measured.put(DAY.plusDays(i), 10.0);
radiation.put(DAY.plusDays(i), 5.0);
}
assertNull(PvCalibration.factor(measured, radiation));
}
@Test
void noRadiationGivesNoFactor() {
Map<LocalDate, Double> days = new HashMap<>();
for (int i = 0; i < 5; i++) {
days.put(DAY.plusDays(i), 0.0);
}
assertNull(PvCalibration.factor(Map.of(), days));
assertNull(PvCalibration.factor(days, days));
}
}
+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": "CWU eko", "oldValue": 45, "newValue": 8,
"unit": "°C", "who": "automatycznie (przywrócenie)", "status": "APPLIED" },
{ "time": "2026-10-23T18:00:00Z", "source": "battery", "what": "Przedział 3: ładowanie z sieci", "oldValue": 0,
"newValue": 1, "unit": "0/1", "who": "mariusz", "status": "FAILED" },
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "CO komfort", "oldValue": null, "newValue": 22,
"unit": "°C", "who": "mariusz", "status": "REJECTED" }
]
}
@@ -0,0 +1 @@
{ "status": "FAILED", "message": "registers not as written: [167]; not retried", "read": { "167": 80 } }
@@ -0,0 +1 @@
{ "pct": 60 }
@@ -0,0 +1,7 @@
{
"enabled": true,
"slots": [
{ "slot": 2, "from": "09:30", "socPct": 90, "gridCharge": true },
{ "slot": 3, "socPct": 85 }
]
}
@@ -0,0 +1,19 @@
{
"time": "2026-10-23T22:26:00Z",
"stale": false,
"socPct": 99.0,
"powerW": -24.0,
"voltageV": 56.49,
"currentA": -0.05,
"temperatureC": 19.2,
"capacityKwh": null,
"today": { "chargeKwh": 0.5, "dischargeKwh": 0.1 },
"total": { "chargeKwh": 453.7, "dischargeKwh": 162.1 },
"mode": null,
"schedule": [],
"limits": null,
"settingsTime": "2026-10-23T21:00:00Z",
"settingsStale": true,
"reserve": { "pct": 50, "floorPct": 30 },
"editable": true
}
@@ -0,0 +1,38 @@
{
"time": "2026-10-23T22:26:00Z",
"stale": false,
"socPct": 99.0,
"powerW": -24.0,
"voltageV": 56.49,
"currentA": -0.05,
"temperatureC": 19.2,
"capacityKwh": 14.336,
"today": { "chargeKwh": 0.5, "dischargeKwh": 0.1 },
"total": { "chargeKwh": 453.7, "dischargeKwh": 162.1 },
"mode": {
"touEnabled": false,
"description": "Rezerwa na awarię: bateria nie rozładowuje się na co dzień"
},
"schedule": [
{ "from": "01:00", "to": "05:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "05:00", "to": "09:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "09:00", "to": "13:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "13:00", "to": "17:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "17:00", "to": "21:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "21:00", "to": "01:00", "powerW": 10000, "socPct": 80, "gridCharge": false }
],
"limits": {
"maxChargeA": 80,
"maxDischargeA": 120,
"shutdownSocPct": 20,
"lowSocPct": 35,
"restartSocPct": 50,
"gridChargeEnabled": true,
"gridChargeCurrentA": 40,
"gridChargeStartSocPct": 30
},
"settingsTime": "2026-10-23T22:26:00Z",
"settingsStale": false,
"reserve": { "pct": 50, "floorPct": 30 },
"editable": true
}
@@ -5,7 +5,10 @@
"groups": [
{ "title": "Bateria", "items": [
{ "label": "Naładowanie", "value": 99.0, "unit": "%", "digits": 0 },
{ "label": "Moc", "value": -24.0, "unit": "W", "digits": 0 }
{ "label": "Moc", "value": -24.0, "unit": "W", "digits": 0 },
{ "label": "Napięcie", "value": 56.5, "unit": "V", "digits": 1 },
{ "label": "Prąd", "value": 0.0, "unit": "A", "digits": 1 },
{ "label": "Temperatura", "value": 19.2, "unit": "°C", "digits": 1 }
] },
{ "title": "Sieć", "items": [
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
@@ -21,7 +24,13 @@
{ "label": "Produkcja PV", "value": 29.9, "unit": "kWh", "digits": 1 },
{ "label": "Zużycie domu", "value": 24.2, "unit": "kWh", "digits": 1 },
{ "label": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 }
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 },
{ "label": "Bateria naładowana", "value": 0.5, "unit": "kWh", "digits": 1 },
{ "label": "Bateria rozładowana", "value": 0.1, "unit": "kWh", "digits": 1 }
] },
{ "title": "Od początku", "items": [
{ "label": "Bateria naładowana", "value": 454.0, "unit": "kWh", "digits": 0 },
{ "label": "Bateria rozładowana", "value": 162.0, "unit": "kWh", "digits": 0 }
] }
]
},
@@ -0,0 +1,7 @@
{
"time": "2026-10-23T21:00:00Z",
"stale": true,
"temperature": null,
"humidity": null,
"pressure": null
}
@@ -0,0 +1,7 @@
{
"time": "2026-10-23T22:26:00Z",
"stale": false,
"temperature": 20.17,
"humidity": 45.82,
"pressure": 985.8
}
@@ -0,0 +1 @@
{ "until": "2026-11-02T18:00:00+01:00" }
@@ -0,0 +1 @@
{ "until": "2026-11-02T18:00:00+01:00", "values": { "cwu_comfort": 12, "co_comfort": 16 } }
@@ -0,0 +1 @@
{ "outcome": "RESTORED", "message": "restored to 8" }
@@ -17,6 +17,7 @@
{
"id": "away",
"name": "Wyjazd",
"until": "chosen",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
+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]}}
@@ -0,0 +1,38 @@
# ADR-012: Away is held until a chosen date, without renewals
Status: accepted · 2026-10-24
Amends [ADR-011](011-setpoint-leases.md) for held lowering changes; everything else there stands.
## Context
With ADR-011 the preset "Wyjazd" (CWU 10, CO 15) lasts only while the backend and the tunnel
renew its lease. A server or tunnel outage during a trip would end the away mode and heat an
empty house. The away values only lower temperatures, so keeping them is the safe direction.
## Decision
- The user picks a return date and time on a calendar. "Wyjazd" is held until then and the
gateway restores the previous values by itself, with no renewals.
- `POST /commands` takes an optional `until` (ISO-8601 with offset), exclusive with `minutes`.
The change is stored as a restore with `leaseUntil = due = until`: no new file field, `runAt`
is the end, and a restart keeps it. Backend: preset field `"until": "chosen"` and
`POST /api/presets/{id}` with `{"until": …}`; 400 when it is missing, invalid or not wanted.
- The gateway enforces the safety, not the backend. A held change is rejected, with no write,
unless the end is in the future and at most 30 days ahead (`Command.MAX_HOLD`), and the new
value is lower than the register's current value and lower than the original that will come
back. Raising stays on renewed leases.
- `renewLeases` only extends: `leaseUntil = max(leaseUntil, now + 15 min)`. A held change keeps
its end, and a restore that is due, lapsed or failing is still never revived.
- A pending boost: a held change may replace it, keeping the first original. Because it must be
below that original, nothing above the original outlives the boost. A later boost on a held
change is an ordinary lease again, which can only shorten the hold.
- The restore at the end uses the usual path: conflict check, read-back, backoff.
## Consequences
- During a trip the house stays on away values even if the backend, tunnel or server are lost.
If the gateway or the Pi is lost, the heat pump keeps the away values until someone
intervenes; the 30 days and the lowering-only rule bound the cost.
- A held away cannot be cancelled by lapsing; the user sets the values back, which replaces it.
- Wyjazd is refused when a value is already at or below the away value (nothing to lower).
@@ -0,0 +1,36 @@
# ADR-013: Battery settings are changed from the app
Status: accepted · 2026-10-24
## Context
The Deye battery is the house's UPS: Time of Use is off and the battery stays full. My father wants to
change its program from our app instead of the Solarman cloud. A wrong table can empty the battery
before a power cut, so the write path is narrow and refuses when unsure.
## Decision
- **Allowed registers only** (FC16, through the one logger link): 146 bit 0 (Time of Use switch; the days
in bits 1–7 stay as read), 148–153 slot starts, 166–171 slot SOC, 172–177 grid charge (0 or 1, never the
generator values 2 and 3). Nothing else is written.
- **Rules**: SOC from the reserve to 100; starts HHMM, strictly increasing from slot 1 to 6; an enabled
schedule needs every slot SOC at or above the reserve. The whole table is checked before any write.
- **Reserve**: an app setting in `reserve.json` on the Pi, default 50 %, never below the configured floor
(30) nor below the inverter's shutdown SOC + 10. It is only raised above a slot SOC while the schedule is
off; the user lifts the slots first. Lowering is always fine.
- **Refuse on unknown state**: no reading of the settings in the last 30 minutes, or the registers read just
before writing differ from that reading (the Solarman app changed them): 409, nothing written.
- **Verify, never retry**: neighbouring registers go in one FC16, the switch last; all are read back; any
mismatch is FAILED with what was read. The user decides what to do next.
- **No restore, no lease**: these are persistent settings as in the Solarman app; the reserve is what
protects the UPS role (ADR-011 stays for the heat pump).
- **Audit and limits**: S3 into `electricity.deye_settings_audit`, S4 per register, token and throttle as
`/commands`. Off unless `deyeCommands.enabled`, which needs the Deye.
## Consequences
- The gateway writes to the inverter for the first time; registers beyond the list stay read-only.
- A change made in the Solarman app while the app shows the old table is refused, not overwritten.
- The reserve is enforced only through the slot SOC rule; the inverter does not know it.
- The shutdown SOC comes from the last settings reading; a change of it applies from the next poll.
- The Solarman cloud stays outside our system; the logger link has one writer at a time.
+2 -1
View File
@@ -39,7 +39,8 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|---|---|---|
| M2 Understand the controller | Timing (safe poll rate, response times), which pumps run in which state, open questions in `docs/frisko/` | `docs/frisko/` reviewed; F3 limits agreed |
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; Traefik route, Krosno only and no login for now (ADR-009). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); open: `REGISTRY_TOKEN`, first release, the stack in homelab-compose (Arcane) | My father sees the heat pump and the energy on his phone |
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011), away held until a chosen date (ADR-012); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
| M4b Battery settings | F8 (ADR-013): Time of Use table and backup reserve from the Bateria view, FC16 with read-back, refused on stale or changed state; on branch/master and off until `deyeCommands.enabled` (gateway/README.md: Enabling battery commands) | Father changes the battery program from the app; first live write watched |
| M5 Alarms | F4; decide the channel first (e.g. ntfy, Telegram) | Simulated alarm reaches the phone |
| M6 Native app? | Decide: keep the web UI, WebView wrapper or React Native | Decision recorded |
+16 -7
View File
@@ -20,7 +20,7 @@ In:
- Access from outside through my server; Polish UI for my parents.
Out, until decided otherwise:
- Writing to the inverters or the battery, and controlling PV.
- Writing to the inverters beyond the battery program and reserve of F8, and controlling PV.
- Editing the weekly program of the heat pump.
- Cloud services (Solarman, Frisko Online) and the logger's push to a second server.
- Replacing Grafana.
@@ -74,26 +74,35 @@ Layers in both apps, dependencies point inwards only:
Functional:
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
heater, alarm, current targets and temperatures.
- F2 History for the last 7 days.
heater, alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
pressure) of the living room.
- F2 History for the last 7 days, and per-day heat pump statistics for 7 or 30 days (compressor, CWU, CO and heater hours,
starts "at least" because polls are 5 minutes apart).
- F3 Change four setpoints: economy and comfort temperature of CWU (4143, 4144) and CO
(4131, 4132). Any value within limits, or one tap on a named preset. Limits and presets live in
the backend config: CWU 5–48 °C (decided; the gateway's ceiling stays 50), CO 5–25 °C.
- **Boost**: raise the economy temperature for a duration the user picks; the previous value
is restored automatically.
- **Away / not needed**: lower the comfort temperature (e.g. CWU 10 °C, CO 15 °C) until changed back.
- **Lease** (ADR-011): every change also needs the backend to renew it every 5 minutes; after
- **Away**: lower the comfort temperature (CWU 10 °C, CO 15 °C by default, lower if the user chooses) until a return date and time the
user picks (at most 30 days). Held by the gateway without renewals (ADR-012), lowering only.
- **Lease** (ADR-011): every other change also needs the backend to renew it every 5 minutes; after
15 minutes without, the gateway restores the previous value.
- **Restore now**: the user can end any pending change at once; the previous value comes back
through the same checks.
- F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery).
- F5 Health of backend, device links and tunnel.
- F6 Later, low priority: show the weekly program (reading needs selector writes).
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals; a details panel per device (Deye, Fronius, house meter).
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals; a details panel per device (Deye, Fronius, house meter); a battery view with SOC, power, the Deye's program schedule and limits.
- F8 Battery settings (ADR-013, off until enabled): change the Deye's Time of Use switch and the six slots (start, SOC, grid charge) from the Bateria view, and the app's backup reserve (default 50 %, floor 30 %) that every enabled slot SOC must hold. Refused when the settings reading is older than 30 min or changed meanwhile; read back, no restore.
- F9 Weather and warnings: a 3-day forecast (Open-Meteo) with a PV estimate calibrated on the last 14 measured days, and IMGW warnings for Krosno, as a card and banners on Podgląd; a storm or wind warning hints on Bateria to keep the battery full. Read-only, from the backend in Kraków.
Safety, for every write:
- S1 Only allowlisted registers, each with min, max and step.
- S2 Read back and verify after writing.
- S3 Audit log: who, when, register, old and new value.
- S3 Audit log: who, when, register, old and new value; shown in the app as the change history.
- S4 Rate limit per register.
- S5 Tests and development never write to a real device or the production database.
- S6 The gateway accepts commands only from the backend: a shared token, and a firewall rule on
+23
View File
@@ -16,3 +16,26 @@ the sizes and the pipe masks in `src/assets/` from it.
The release tag is baked in at build (`VERSION` build arg, `dev` by default) and shown with the backend's
and gateway's versions in the "Wersje" card of the Stan screen.
The Energia screen has three views, kept in the URL hash so Back works: `#energy` (Teraz), `#energy/battery` (Bateria, also
opened by tapping the battery in the flow diagram) and `#energy/inverters` (Falowniki, the device details).
Ustawienia ends with the "Historia zmian" card (`ChangeHistory.tsx`): the last 7 days of `api/audit`, 20 rows and "Pokaż więcej".
In the Bateria view, `editable` from `api/battery` (the gateway takes battery changes, ADR-013) adds the reserve stepper and "Edytuj" for the
schedule: the user confirms a summary, only changed fields are sent to `api/battery/schedule` and `api/battery/reserve`, and the UI
checks the gateway's rules first (levels from the reserve to 100 %, starts increasing).
Podgląd shows the "Pogoda" card (3 days, PV estimate) and one banner per IMGW warning from `api/weather`; Bateria hints to keep the battery full on a storm or strong-wind warning (a hint only, nothing changes by itself).
Historia has two views in the hash, like Energia: `#history` (Wykresy) and `#history/stats` (Statystyki: totals and a daily chart of
compressor hours from `api/stats`, 7 or 30 days). `SubView.tsx` holds the shared tab switch.
## Install on a phone (PWA)
The build has a manifest (`public/`), icons and a service worker (`src/sw.js`, written to `dist/sw.js` with the list of built
files by `precachePlugin` in `vite.config.ts`). The shell opens offline; `/api/` is never cached. nginx sends `sw.js` and
`index.html` with `no-cache`, so an update is picked up at the next open.
- Android (Chrome): menu, "Zainstaluj aplikację" / "Dodaj do ekranu głównego".
- iPhone (Safari): Share, "Dodaj do ekranu początkowego". It must be Safari, and over https.
+7 -1
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" }
]
}
+10 -8
View File
@@ -1,15 +1,17 @@
import { useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState, type ComponentType } from "react";
import { celsius, hour, longDate } from "./format";
import { Icon, type IconName } from "./icons";
import { Energy } from "./Energy";
import { History } from "./History";
import { Overview } from "./Overview";
import { usePoll } from "./poll";
import { Settings } from "./Settings";
import { Status } from "./Status";
import { useTheme, type Theme } from "./theme";
import type { HeatPump } from "./types";
import { Dot } from "./ui";
import { Dot, Loading } from "./ui";
// Energy shares code with the first screen; the others load when opened.
const History = lazy(() => import("./History").then((m) => ({ default: m.History })));
const Settings = lazy(() => import("./Settings").then((m) => ({ default: m.Settings })));
const Status = lazy(() => import("./Status").then((m) => ({ default: m.Status })));
const SCREENS = {
overview: { label: "Podgląd", icon: "home", view: Overview },
@@ -17,11 +19,11 @@ const SCREENS = {
history: { label: "Historia", icon: "chart", view: History },
settings: { label: "Ustawienia", icon: "sliders", view: Settings },
status: { label: "Stan", icon: "check", view: Status },
} satisfies Record<string, { label: string; icon: IconName; view: () => unknown }>;
} satisfies Record<string, { label: string; icon: IconName; view: ComponentType }>;
type ScreenId = keyof typeof SCREENS;
const fromHash = (): ScreenId => {
const id = location.hash.slice(1);
const id = location.hash.slice(1).split("/")[0];
return id in SCREENS ? (id as ScreenId) : "overview";
};
@@ -111,7 +113,7 @@ export function App() {
<div className="hidden flex-1 lg:flex lg:flex-col"><SideFooter /></div>
</nav>
<main className="mx-auto max-w-[1500px] px-4 pt-5 pb-28 sm:px-6 lg:px-8 lg:pt-8 lg:pb-10">
<View />
<Suspense fallback={<Loading />}><View /></Suspense>
<ThemeSwitch className="mx-auto mt-8 max-w-xs border border-edge bg-panel text-ink lg:hidden" />
</main>
</div>
+107
View File
@@ -0,0 +1,107 @@
import { render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { Battery as BatteryData } from "./types";
// A fresh module per test: the poller keeps its answers between screens, not between tests.
async function show(patch: Partial<BatteryData> = {}) {
serve({ "api/battery": { ...example<BatteryData>("battery"), ...patch } });
const { Battery } = await import("./Battery");
render(<Battery />);
await screen.findByText("Bateria teraz");
}
const touOn: Partial<BatteryData> = {
mode: { touEnabled: true, description: "Harmonogram: bateria pracuje według przedziałów" },
};
beforeEach(() => {
location.hash = "";
});
describe("Battery view", () => {
it("shows the charge with its kWh, and no flow as standing still", async () => {
await show({ powerW: 3 });
expect(screen.getByRole("img", { name: "Naładowanie baterii: 99 %" })).toBeInTheDocument();
expect(screen.getByText("14,2 kWh z 14,3 kWh")).toBeInTheDocument();
expect(screen.getByText("stoi")).toBeInTheDocument();
});
it("says charging in words with the size of the flow", async () => {
await show({ powerW: -1500 });
expect(screen.getByText("ładuje")).toBeInTheDocument();
expect(screen.getByText("1,5 kW")).toBeInTheDocument();
});
it("says discharging for a positive power", async () => {
await show({ powerW: 400 });
expect(screen.getByText("rozładowuje")).toBeInTheDocument();
expect(screen.getByText("400 W")).toBeInTheDocument();
});
it("explains the backup mode and dims the schedule as wyłączony", async () => {
await show();
expect(screen.getByText("Rezerwa na awarię: bateria nie rozładowuje się na co dzień")).toBeInTheDocument();
const table = screen.getByRole("table");
expect(screen.getByText("wyłączony")).toBeInTheDocument();
expect(table).toHaveClass("opacity-50");
expect(within(table).getAllByRole("row")).toHaveLength(7);
expect(within(table).getByRole("row", { name: /21:00–01:00/ })).toHaveTextContent("80 %nie");
});
it("shows an active schedule at full strength with the grid charging per slot", async () => {
const slots = example<BatteryData>("battery").schedule.map((s, i) => ({ ...s, gridCharge: i === 0 }));
await show({ ...touOn, schedule: slots });
const table = screen.getByRole("table");
expect(screen.queryByText("wyłączony")).toBeNull();
expect(table).not.toHaveClass("opacity-50");
expect(within(table).getByRole("row", { name: /01:00–05:00/ })).toHaveTextContent("tak");
expect(within(table).getByRole("row", { name: /05:00–09:00/ })).toHaveTextContent("nie");
});
it("lists the limits, today's and the total energy and when the settings were read", async () => {
await show();
expect(screen.getByText("Maks. prąd ładowania").parentElement).toHaveTextContent("80 A");
expect(screen.getByText("Wyłączenie przy").parentElement).toHaveTextContent("20 %");
expect(screen.getByText("Prąd ładowania z sieci").parentElement).toHaveTextContent("40 A");
expect(screen.getByText("Dziś naładowano").parentElement).toHaveTextContent("0,5 kWh");
expect(screen.getByText("Od początku rozładowano").parentElement).toHaveTextContent("162,1 kWh");
expect(screen.getByText(/Ustawienia odczytane: \d\d:\d\d/)).toBeInTheDocument();
});
it("hides the grid charging details when it is off", async () => {
const limits = { ...example<BatteryData>("battery").limits!, gridChargeEnabled: false };
await show({ limits });
const card = screen.getByRole("heading", { name: "Limity" }).closest("section")!;
expect(within(card).getByText("Ładowanie z sieci").parentElement).toHaveTextContent("nie");
expect(screen.queryByText("Prąd ładowania z sieci")).toBeNull();
});
it("without current settings shows a warning instead of a stale schedule", async () => {
await show({ mode: null, schedule: [], limits: null, capacityKwh: null, settingsStale: true,
settingsTime: "2026-10-23T21:00:00Z" });
expect(screen.getByText(/Ustawienia baterii są nieaktualne \(ostatnio \d\d:\d\d\)/)).toBeInTheDocument();
expect(screen.queryByRole("table")).toBeNull();
expect(screen.queryByText("Limity")).toBeNull();
expect(screen.queryByText(/Ustawienia odczytane/)).toBeNull();
expect(screen.getByRole("img", { name: "Naładowanie baterii: 99 %" })).toBeInTheDocument();
});
it("without a live reading shows dashes and a stale warning", async () => {
await show({ stale: true, time: "2026-10-23T21:00:00Z", socPct: null, powerW: null, voltageV: null,
currentA: null, temperatureC: null, today: { chargeKwh: null, dischargeKwh: null },
total: { chargeKwh: null, dischargeKwh: null } });
expect(screen.getByText(/Dane są nieaktualne/)).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Naładowanie baterii: —" })).toBeInTheDocument();
expect(screen.getByText("Dziś naładowano").parentElement).toHaveTextContent("—");
});
});
+123
View File
@@ -0,0 +1,123 @@
import { useState } from "react";
import { flowing } from "./flow";
import { amps, DASH, hour, kwh, number, percent, power } from "./format";
import { usePoll } from "./poll";
import type { Battery as BatteryData } from "./types";
import { Reserve, Schedule, type Notice } from "./BatterySettings";
import { Banner, Card, Freshness, Loading, Row } from "./ui";
import { threatensPower, useWeather } from "./Weather";
/** What the battery does, in one word: + discharge / − charge. */
export function batteryAction(watts: number | null) {
if (watts == null) return DASH;
if (!flowing(watts)) return "stoi";
return watts > 0 ? "rozładowuje" : "ładuje";
}
const RING = { size: 168, stroke: 16 };
const RADIUS = (RING.size - RING.stroke) / 2;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function Gauge({ soc, stored, capacity }: { soc: number | null; stored: number | null; capacity: number | null }) {
const filled = soc == null ? 0 : (Math.min(100, Math.max(0, soc)) / 100) * CIRCUMFERENCE;
const centre = RING.size / 2;
return (
<div className="relative size-44 shrink-0">
<svg viewBox={`0 0 ${RING.size} ${RING.size}`} role="img" aria-label={`Naładowanie baterii: ${percent(soc)}`} className="size-full -rotate-90">
<circle cx={centre} cy={centre} r={RADIUS} fill="none" stroke="var(--color-edge)" strokeWidth={RING.stroke} />
<circle cx={centre} cy={centre} r={RADIUS} fill="none" stroke="var(--color-brand)" strokeWidth={RING.stroke}
strokeLinecap="round" strokeDasharray={`${filled} ${CIRCUMFERENCE}`} />
</svg>
<div className="absolute inset-0 grid place-items-center text-center">
<div>
<p className="text-4xl font-bold tabular-nums">{percent(soc)}</p>
{stored != null && capacity != null && <p className="text-sm text-soft">{kwh(stored)} z {kwh(capacity)}</p>}
</div>
</div>
</div>
);
}
function Now({ b }: { b: BatteryData }) {
const stored = b.socPct != null && b.capacityKwh != null ? (b.socPct / 100) * b.capacityKwh : null;
return (
<Card title="Bateria teraz">
<div className="flex flex-wrap items-center gap-5">
<Gauge soc={b.socPct} stored={stored} capacity={b.capacityKwh} />
<div className="min-w-44 flex-1">
<p className={`text-2xl font-bold ${flowing(b.powerW) ? "text-run" : ""}`}>
{batteryAction(b.powerW)}
{flowing(b.powerW) && <span className="tabular-nums"> {power(Math.abs(b.powerW!))}</span>}
</p>
<div className="mt-2 text-sm">
<Row label="Napięcie" value={b.voltageV == null ? DASH : `${number(b.voltageV, 1)} V`} />
<Row label="Prąd" value={b.currentA == null ? DASH : `${number(Math.abs(b.currentA), 1)} A`} />
<Row label="Temperatura" value={b.temperatureC == null ? DASH : `${number(b.temperatureC, 1)} °C`} />
</div>
</div>
</div>
</Card>
);
}
function Limits({ b }: { b: BatteryData }) {
const l = b.limits!;
return (
<Card title="Limity">
<Row label="Maks. prąd ładowania" value={amps(l.maxChargeA)} />
<Row label="Maks. prąd rozładowania" value={amps(l.maxDischargeA)} />
<Row label="Wyłączenie przy" value={percent(l.shutdownSocPct)} />
<Row label="Niski poziom" value={percent(l.lowSocPct)} />
<Row label="Ponowne włączenie przy" value={percent(l.restartSocPct)} />
<Row label="Ładowanie z sieci" value={l.gridChargeEnabled ? "tak" : "nie"} />
{l.gridChargeEnabled && <Row label="Prąd ładowania z sieci" value={amps(l.gridChargeCurrentA)} />}
{l.gridChargeEnabled && <Row label="Ładowanie z sieci od" value={percent(l.gridChargeStartSocPct)} />}
</Card>
);
}
function Totals({ b }: { b: BatteryData }) {
return (
<Card title="Energia">
<Row label="Dziś naładowano" value={kwh(b.today.chargeKwh)} />
<Row label="Dziś rozładowano" value={kwh(b.today.dischargeKwh)} />
<Row label="Od początku naładowano" value={kwh(b.total.chargeKwh)} />
<Row label="Od początku rozładowano" value={kwh(b.total.dischargeKwh)} />
</Card>
);
}
export function Battery() {
const { data: b, offline } = usePoll<BatteryData>("api/battery");
const [notice, setNotice] = useState<Notice>(null);
const weather = useWeather();
if (!b) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
return (
<>
<Freshness offline={offline} time={b.time} stale={b.stale} />
{weather?.warnings.some(threatensPower) && (
<Banner tone="warn">Ostrzeżenie IMGW: rozważ trzymanie baterii pełnej (rezerwa na awarię).</Banner>
)}
{notice && <Banner tone={notice.ok ? "ok" : "bad"}>{notice.text}</Banner>}
<div className="grid gap-4 lg:grid-cols-2">
<div className="lg:col-span-2"><Now b={b} /></div>
{b.settingsStale || !b.mode || !b.limits ? (
<div className="lg:col-span-2">
<Banner tone="warn">Ustawienia baterii są nieaktualne{b.settingsTime ? ` (ostatnio ${hour(new Date(b.settingsTime))})` : ", brak odczytu"}.</Banner>
</div>
) : (
<>
<Card title="Tryb pracy" className="lg:col-span-2"><p className="text-lg font-semibold">{b.mode.description}</p></Card>
<Schedule b={b} onDone={setNotice} />
<Limits b={b} />
</>
)}
{b.reserve && <Reserve b={b} onDone={setNotice} />}
<div className="lg:col-span-2"><Totals b={b} /></div>
</div>
{b.settingsTime && !b.settingsStale && (
<p className="mt-4 text-center text-sm text-soft">Ustawienia odczytane: {hour(new Date(b.settingsTime))}</p>
)}
</>
);
}
+267
View File
@@ -0,0 +1,267 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { answer, example, serve } from "./fixtures";
import type { Battery as BatteryData } from "./types";
const touOn = { touEnabled: true, description: "Harmonogram: bateria pracuje według przedziałów" };
// A fresh module per test: the poller keeps its answers between screens, not between tests.
async function show(patch: Partial<BatteryData> = {}, onPost = () => answer({ status: "APPLIED", message: "applied", read: {} })) {
const fetch = serve({ "api/battery": { ...example<BatteryData>("battery"), ...patch } }, onPost);
const { Battery } = await import("./Battery");
render(<Battery />);
await screen.findByText("Bateria teraz");
return fetch;
}
const posts = (fetch: ReturnType<typeof serve>) =>
fetch.mock.calls.filter(([, init]) => init?.method === "POST").map(([url, init]) => [url, JSON.parse(String(init!.body))]);
const click = (name: string | RegExp, role = "button") => userEvent.click(screen.getByRole(role, { name }));
const more = (label: string) => userEvent.click(screen.getByLabelText(`Więcej: ${label}`));
const less = (label: string) => userEvent.click(screen.getByLabelText(`Mniej: ${label}`));
const edit = async () => click("Edytuj");
// The staleness banner of the old fixture is an alert too.
const alerts = () => screen.queryAllByRole("alert").map((el) => el.textContent ?? "").filter((text) => !text.startsWith("Dane są nieaktualne"));
beforeEach(() => {
location.hash = "";
window.scrollTo = () => {};
});
describe("Battery reserve", () => {
it("shows the reserve with its explanation", async () => {
await show();
expect(screen.getByText("Rezerwa na awarię").parentElement).toHaveTextContent("50 %");
expect(screen.getByText(/Bateria nie rozładuje się poniżej tej wartości na co dzień; przy braku prądu zostaje dla domu\./)).toBeInTheDocument();
});
it("steps within the bounds of the API and writes only after the summary is confirmed", async () => {
const fetch = await show();
for (let i = 0; i < 6; i++) await less("rezerwa na awarię");
expect(screen.getByLabelText("Mniej: rezerwa na awarię")).toBeDisabled();
expect(screen.getByText("od 30 % do 100 %")).toBeInTheDocument();
for (let i = 0; i < 2; i++) await more("rezerwa na awarię");
await click("Zapisz");
expect(posts(fetch)).toEqual([]);
expect(screen.getByRole("group", { name: "Potwierdzenie zmiany" })).toHaveTextContent("Rezerwa na awarię: z 50 % na 40 %");
await click("Tak, zapisz");
expect(posts(fetch)).toEqual([["api/battery/reserve", { pct: 40 }]]);
expect(await screen.findByText(/^Zapisano\./)).toBeInTheDocument();
});
it("cannot go above 100 %", async () => {
await show({ reserve: { pct: 98, floorPct: 30 } });
await more("rezerwa na awarię");
expect(screen.getByLabelText("Więcej: rezerwa na awarię")).toBeDisabled();
expect(screen.getByRole("button", { name: "Zapisz" })).toBeEnabled();
});
it("refuses to raise it above a slot level while the schedule is enabled", async () => {
const fetch = await show({ mode: touOn, reserve: { pct: 78, floorPct: 30 } });
await more("rezerwa na awarię");
expect(alerts()).toEqual([expect.stringContaining("Najpierw podnieś poziomy w harmonogramie albo go wyłącz.")]);
expect(screen.getByRole("button", { name: "Zapisz" })).toBeDisabled();
expect(posts(fetch)).toEqual([]);
});
it("allows raising it while the schedule is off", async () => {
await show({ reserve: { pct: 78, floorPct: 30 } });
await more("rezerwa na awarię");
expect(alerts()).toEqual([]);
expect(screen.getByRole("button", { name: "Zapisz" })).toBeEnabled();
});
it("anuluj drops the draft", async () => {
const fetch = await show();
await less("rezerwa na awarię");
await click("Anuluj");
expect(screen.getByLabelText("Mniej: rezerwa na awarię").parentElement).toHaveTextContent("50 %");
expect(posts(fetch)).toEqual([]);
});
it("is only shown, not editable, when the gateway takes no changes", async () => {
await show({ editable: false });
expect(screen.getByText("Rezerwa na awarię").parentElement).toHaveTextContent("50 %");
expect(screen.queryByLabelText("Mniej: rezerwa na awarię")).toBeNull();
expect(screen.queryByRole("button", { name: "Edytuj" })).toBeNull();
});
it("has no reserve card without a reserve", async () => {
await show({ reserve: null, editable: false });
expect(screen.queryByText("Rezerwa na awarię")).toBeNull();
expect(screen.getByRole("table")).toBeInTheDocument();
});
});
describe("Battery schedule editing", () => {
it("edits the switch, a start, a level and the grid charging and sends only what changed", async () => {
const fetch = await show();
await edit();
await click("Harmonogram włączony", "switch");
fireEvent.change(screen.getByLabelText("Początek przedziału 2"), { target: { value: "07:00" } });
await more("poziom, przedział 3");
await click("Ładowanie z sieci, przedział 1", "switch");
await click("Zapisz");
const summary = screen.getByRole("group", { name: "Potwierdzenie zmiany" });
expect(summary).toHaveTextContent("Harmonogram: włączony");
expect(summary).toHaveTextContent("Przedział 1: ładowanie z sieci nie → tak");
expect(summary).toHaveTextContent("Przedział 2: początek 05:00 → 07:00");
expect(summary).toHaveTextContent("Przedział 3: poziom 80 % → 85 %");
expect(posts(fetch)).toEqual([]);
await click("Tak, zapisz");
expect(posts(fetch)).toEqual([["api/battery/schedule", { enabled: true, slots: [
{ slot: 1, gridCharge: true }, { slot: 2, from: "07:00" }, { slot: 3, socPct: 85 }] }]]);
expect(await screen.findByText(/^Zapisano\./)).toBeInTheDocument();
expect(screen.queryByText("Harmonogram: edycja")).toBeNull();
});
it("starts from the current table and can go back without sending", async () => {
const fetch = await show({ mode: touOn });
await edit();
expect(screen.getByRole("switch", { name: "Harmonogram włączony" })).toBeChecked();
expect(screen.getByLabelText("Początek przedziału 4")).toHaveValue("13:00");
expect(screen.getByRole("button", { name: "Zapisz" })).toBeDisabled();
await click("Anuluj");
expect(screen.getByRole("table")).toBeInTheDocument();
expect(posts(fetch)).toEqual([]);
});
it("makes a level below the reserve impossible", async () => {
await show();
await edit();
for (let i = 0; i < 8; i++) await less("poziom, przedział 1");
expect(screen.getByLabelText("Mniej: poziom, przedział 1").parentElement).toHaveTextContent("50 %");
expect(screen.getByLabelText("Mniej: poziom, przedział 1")).toBeDisabled();
});
it("takes the reserve from the API as the lowest level", async () => {
await show({ reserve: { pct: 80, floorPct: 30 } });
await edit();
expect(screen.getByLabelText("Mniej: poziom, przedział 2")).toBeDisabled();
expect(screen.getByText(/nie może być niższy niż rezerwa na awarię \(80 %\)/)).toBeInTheDocument();
});
it("cannot be enabled while a level is below the reserve", async () => {
const schedule = example<BatteryData>("battery").schedule.map((slot, i) => ({ ...slot, socPct: i === 2 ? 40 : 80 }));
await show({ schedule });
await edit();
await click("Harmonogram włączony", "switch");
expect(alerts()).toEqual([expect.stringContaining("Poziom przedziału 3 (40 %) jest niższy niż rezerwa (50 %).")]);
expect(screen.getByRole("button", { name: "Zapisz" })).toBeDisabled();
});
it("requires the starts to increase from slot 1 to 6", async () => {
await show();
await edit();
fireEvent.change(screen.getByLabelText("Początek przedziału 2"), { target: { value: "01:00" } });
expect(alerts()).toEqual(["Godziny początku muszą rosnąć od przedziału 1 do 6."]);
expect(screen.getByRole("button", { name: "Zapisz" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("Początek przedziału 2"), { target: { value: "01:01" } });
expect(alerts()).toEqual([]);
expect(screen.getByRole("button", { name: "Zapisz" })).toBeEnabled();
});
it("requires every start to be filled in", async () => {
await show();
await edit();
fireEvent.change(screen.getByLabelText("Początek przedziału 5"), { target: { value: "" } });
expect(alerts()).toEqual(["Podaj godzinę początku przedziału 5."]);
expect(screen.getByRole("button", { name: "Zapisz" })).toBeDisabled();
});
it("lets the last slot run to the start of the first", async () => {
await show();
await edit();
expect(within(screen.getByRole("group", { name: /^Przedział 6/ })).getByText("(do 01:00)")).toBeInTheDocument();
});
it("is not offered when the gateway takes no changes or the settings are stale", async () => {
await show({ editable: false, reserve: null });
expect(screen.queryByRole("button", { name: "Edytuj" })).toBeNull();
});
it("offers nothing to edit while the settings are stale", async () => {
await show({ mode: null, schedule: [], limits: null, capacityKwh: null, settingsStale: true,
settingsTime: "2026-10-23T21:00:00Z" });
expect(screen.queryByRole("button", { name: "Edytuj" })).toBeNull();
expect(screen.queryByLabelText("Mniej: rezerwa na awarię")).not.toBeNull();
});
});
describe("Battery write results", () => {
const cases: [string, number, object, RegExp][] = [
["rejected", 422, { status: "REJECTED", message: "x" }, /Nic nie zostało zmienione\./],
["changed meanwhile", 409, { status: "STALE", message: "x" }, /zmieniły się w międzyczasie.*Nic nie zostało zmienione/],
["too soon", 429, { status: "TOO_SOON", message: "x" }, /Za wcześnie po poprzednim zapisie/],
["failed", 502, { status: "FAILED", message: "x", read: { "167": 80 } },
/Nie wiadomo, czy zmiana doszła do falownika\..*Odczyt z falownika: 167 = 80\./],
];
it.each(cases)("says in Polish what a %s answer means", async (_, status, body, text) => {
await show({}, () => answer(body, status));
await edit();
await click("Harmonogram włączony", "switch");
await click("Zapisz");
await click("Tak, zapisz");
expect(await screen.findByText(text)).toBeInTheDocument();
});
it("explains a bad request and an unavailable gateway, and never retries", async () => {
for (const [status, text] of [[400, /Nieprawidłowa wartość\. Nic nie zostało zmienione\./],
[503, /niedostępne\. Nic nie zostało zmienione\./]] as const) {
const fetch = await show({}, () => answer({ error: "x" }, status));
await less("rezerwa na awarię");
await click("Zapisz");
await click("Tak, zapisz");
expect(await screen.findByText(text)).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(1);
document.body.innerHTML = "";
}
});
it("never claims nothing changed when the answer is lost", async () => {
const fetch = await show({}, () => Promise.reject(new TypeError("network")));
await less("rezerwa na awarię");
await click("Zapisz");
await click("Tak, zapisz");
expect(await screen.findByText(/Nie wiadomo, czy zmiana doszła do falownika/)).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(1);
});
});
+300
View File
@@ -0,0 +1,300 @@
import { useState, type ReactNode } from "react";
import { postJson, WriteError } from "./api";
import { percent } from "./format";
import { reload } from "./poll";
import type { Battery, BatteryChange, BatteryResult, BatterySlot } from "./types";
import { Button, Card, Pill, Row, Stepper } from "./ui";
const URL = "api/battery";
// The gateway polls the settings again 3 s after a write; the backend shows them from then on.
const SETTLE_MS = 5000;
// Taps move the level in fives; the bounds are always reachable because they clamp.
const SOC_STEP = 5;
export type Notice = { ok: boolean; text: string } | null;
const RESULT_TEXT: Record<BatteryResult["status"], string> = {
APPLIED: "Zapisano. Odczyt ustawień z falownika odświeży się za chwilę.",
REJECTED: "Falownik nie dostał tej zmiany, bo nie spełnia zasad. Nic nie zostało zmienione.",
STALE: "Ustawienia baterii zmieniły się w międzyczasie (np. w aplikacji Solarman) albo nie są aktualne. Nic nie zostało zmienione: poczekaj na odświeżenie i spróbuj ponownie.",
TOO_SOON: "Za wcześnie po poprzednim zapisie. Nic nie zostało zmienione, spróbuj za minutę.",
FAILED: "Nie wiadomo, czy zmiana doszła do falownika. Sprawdź ustawienia poniżej, zanim spróbujesz ponownie.",
};
const ERROR_TEXT: Record<number, string> = {
400: "Nieprawidłowa wartość. Nic nie zostało zmienione.",
503: "Zmiany ustawień baterii są teraz niedostępne. Nic nie zostało zmienione.",
};
// A write that loses its answer may still have reached the inverter.
export const WRITE_LOST = "Dom nie odpowiada. Nie wiadomo, czy zmiana doszła do falownika: sprawdź ustawienia, gdy połączenie wróci.";
/** Sends one change and says what came of it; a refusal, a conflict and a failed write are answers, not errors. */
async function submit(path: string, body: unknown): Promise<Notice> {
try {
const result = await postJson<BatteryResult>(`${URL}/${path}`, body);
const read = Object.entries(result.read ?? {}).map(([register, value]) => `${register} = ${value}`).join(", ");
const detail = result.status === "FAILED" && read ? ` Odczyt z falownika: ${read}.` : "";
return { ok: result.status === "APPLIED", text: RESULT_TEXT[result.status] + detail };
} catch (error) {
return { ok: false, text: ERROR_TEXT[error instanceof WriteError ? error.status : 0] ?? WRITE_LOST };
}
}
function refreshSoon() {
reload(URL);
setTimeout(() => reload(URL), SETTLE_MS);
}
/** The summary of a change with "Tak, zapisz" and a way back. */
function Confirm({ children, busy, onConfirm, onBack }: {
children: ReactNode; busy: boolean; onConfirm: () => void; onBack: () => void;
}) {
return (
<div role="group" aria-label="Potwierdzenie zmiany" className="mt-3 rounded-xl border-2 border-brand bg-brand-soft p-4">
<div className="text-lg font-semibold">{children}</div>
<div className="mt-3 flex gap-3">
<Button primary onClick={onConfirm} disabled={busy}>Tak, zapisz</Button>
<Button onClick={onBack} disabled={busy}>Wróć</Button>
</div>
</div>
);
}
function Switch({ label, checked, disabled, onChange }: {
label: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void;
}) {
return (
<button role="switch" aria-checked={checked} aria-label={label} disabled={disabled} onClick={() => onChange(!checked)}
className="flex h-12 w-16 shrink-0 items-center justify-center disabled:opacity-40">
<span className={`flex h-8 w-14 items-center rounded-full border-2 border-brand px-0.5 transition ${checked ? "justify-end bg-brand" : "justify-start bg-panel"}`}>
<span className={`size-6 rounded-full ${checked ? "bg-panel" : "bg-brand"}`} />
</span>
</button>
);
}
const RESERVE_BREAKS_SCHEDULE = "Harmonogram jest włączony, a któryś przedział ma poziom niższy niż nowa rezerwa. Najpierw podnieś poziomy w harmonogramie albo go wyłącz.";
export function Reserve({ b, onDone }: { b: Battery; onDone: (notice: Notice) => void }) {
const reserve = b.reserve!;
const [draft, setDraft] = useState<number | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const lowestSlot = Math.min(...b.schedule.map((slot) => slot.socPct));
// The gateway refuses it too; asking first keeps the phone from a pointless round trip.
const breaksSchedule = draft != null && !!b.mode?.touEnabled && lowestSlot < draft;
const stop = () => { setDraft(null); setConfirming(false); };
const step = (direction: number) => {
const next = Math.min(100, Math.max(reserve.floorPct, (draft ?? reserve.pct) + direction * SOC_STEP));
setDraft(next === reserve.pct ? null : next);
setConfirming(false);
};
async function save() {
setBusy(true);
const notice = await submit("reserve", { pct: draft });
setBusy(false);
stop();
onDone(notice);
window.scrollTo(0, 0);
refreshSoon();
}
return (
<Card className="lg:col-span-2">
<Row label="Rezerwa na awarię" value={percent(reserve.pct)} />
<p className="mt-2 text-sm text-soft">
Bateria nie rozładuje się poniżej tej wartości na co dzień; przy braku prądu zostaje dla domu.
</p>
{b.editable && (
<>
<Stepper label="rezerwa na awarię" value={draft ?? reserve.pct} min={reserve.floorPct} max={100}
format={percent} disabled={busy} onStep={step} />
<p className="text-sm text-soft">od {percent(reserve.floorPct)} do {percent(100)}</p>
{breaksSchedule && <p role="alert" className="mt-2 font-semibold text-alarm">{RESERVE_BREAKS_SCHEDULE}</p>}
{draft != null && !confirming && (
<div className="mt-3 flex gap-3">
<Button primary onClick={() => setConfirming(true)} disabled={busy || breaksSchedule}>Zapisz</Button>
<Button onClick={stop} disabled={busy}>Anuluj</Button>
</div>
)}
{draft != null && confirming && (
<Confirm busy={busy} onConfirm={save} onBack={() => setConfirming(false)}>
Rezerwa na awarię: z {percent(reserve.pct)} na <b>{percent(draft)}</b>
</Confirm>
)}
</>
)}
</Card>
);
}
interface Draft {
enabled: boolean;
slots: { from: string; socPct: number; gridCharge: boolean }[];
}
const draftOf = (b: Battery): Draft => ({
enabled: b.mode!.touEnabled,
slots: b.schedule.map(({ from, socPct, gridCharge }) => ({ from, socPct, gridCharge })),
});
const yesNo = (value: boolean) => (value ? "tak" : "nie");
/** Everything wrong with the draft, in words; the same rules the gateway enforces. */
function problemsOf(draft: Draft, reserve: number): string[] {
const problems: string[] = [];
draft.slots.forEach((slot, i) => {
if (!slot.from) problems.push(`Podaj godzinę początku przedziału ${i + 1}.`);
if (draft.enabled && slot.socPct < reserve) {
problems.push(`Poziom przedziału ${i + 1} (${percent(slot.socPct)}) jest niższy niż rezerwa (${percent(reserve)}).`);
}
});
const starts = draft.slots.map((slot) => slot.from);
if (starts.every(Boolean) && starts.some((from, i) => i > 0 && from <= starts[i - 1])) {
problems.push("Godziny początku muszą rosnąć od przedziału 1 do 6.");
}
return problems;
}
/** The change as the backend takes it and as a sentence per line; empty when nothing differs. */
function changesOf(b: Battery, draft: Draft): { body: BatteryChange; lines: string[] } {
const lines: string[] = [];
const body: BatteryChange = { slots: [] };
if (draft.enabled !== b.mode!.touEnabled) {
body.enabled = draft.enabled;
lines.push(`Harmonogram: ${draft.enabled ? "włączony" : "wyłączony"}`);
}
draft.slots.forEach((slot, i) => {
const old: BatterySlot = b.schedule[i];
const change: BatteryChange["slots"][number] = { slot: i + 1 };
const parts: string[] = [];
if (slot.from !== old.from) { change.from = slot.from; parts.push(`początek ${old.from} → ${slot.from}`); }
if (slot.socPct !== old.socPct) { change.socPct = slot.socPct; parts.push(`poziom ${percent(old.socPct)} → ${percent(slot.socPct)}`); }
if (slot.gridCharge !== old.gridCharge) {
change.gridCharge = slot.gridCharge;
parts.push(`ładowanie z sieci ${yesNo(old.gridCharge)} → ${yesNo(slot.gridCharge)}`);
}
if (parts.length) {
body.slots.push(change);
lines.push(`Przedział ${i + 1}: ${parts.join(", ")}`);
}
});
return { body, lines };
}
function SlotEditor({ n, slot, until, min, disabled, onChange }: {
n: number; slot: Draft["slots"][number]; until: string; min: number; disabled: boolean;
onChange: (slot: Draft["slots"][number]) => void;
}) {
return (
<fieldset className="rounded-xl border border-edge p-3">
<legend className="px-1 font-semibold">Przedział {n}{slot.from && until ? <span className="font-normal text-soft"> (do {until})</span> : null}</legend>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<label className="flex items-center gap-2 text-soft">
Początek
<input type="time" required value={slot.from} disabled={disabled} aria-label={`Początek przedziału ${n}`}
onChange={(event) => onChange({ ...slot, from: event.target.value })}
className="min-h-12 rounded-xl border-2 border-edge bg-panel px-3 text-lg font-semibold text-ink" />
</label>
<div className="min-w-48 flex-1">
<Stepper compact label={`poziom, przedział ${n}`} value={slot.socPct} min={min} max={100} format={percent}
disabled={disabled} onStep={(direction) => onChange({ ...slot,
socPct: Math.min(100, Math.max(min, slot.socPct + direction * SOC_STEP)) })} />
</div>
<div className="flex items-center gap-1 text-soft">
Ładowanie z sieci
<Switch label={`Ładowanie z sieci, przedział ${n}`} checked={slot.gridCharge} disabled={disabled}
onChange={(gridCharge) => onChange({ ...slot, gridCharge })} />
</div>
</div>
</fieldset>
);
}
function ScheduleEditor({ b, onClose, onDone }: { b: Battery; onClose: () => void; onDone: (notice: Notice) => void }) {
const [draft, setDraft] = useState(() => draftOf(b));
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const reserve = b.reserve!.pct;
const problems = problemsOf(draft, reserve);
const { body, lines } = changesOf(b, draft);
const edit = (next: Draft) => { setDraft(next); setConfirming(false); };
async function save() {
setBusy(true);
const notice = await submit("schedule", body);
setBusy(false);
onDone(notice);
onClose();
window.scrollTo(0, 0);
refreshSoon();
}
return (
<Card title="Harmonogram: edycja" className="lg:col-span-2">
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">Harmonogram włączony</span>
<Switch label="Harmonogram włączony" checked={draft.enabled} disabled={busy}
onChange={(enabled) => edit({ ...draft, enabled })} />
</div>
<div className="mt-3 grid gap-3">
{draft.slots.map((slot, i) => (
<SlotEditor key={i} n={i + 1} slot={slot} until={draft.slots[(i + 1) % draft.slots.length].from}
min={reserve} disabled={busy}
onChange={(next) => edit({ ...draft, slots: draft.slots.map((s, j) => (j === i ? next : s)) })} />
))}
</div>
<p className="mt-2 text-sm text-soft">Poziom nie może być niższy niż rezerwa na awarię ({percent(reserve)}). Ostatni przedział trwa do początku pierwszego.</p>
{problems.length > 0 && (
<ul role="alert" className="mt-2 list-disc pl-5 font-semibold text-alarm">
{problems.map((problem) => <li key={problem}>{problem}</li>)}
</ul>
)}
{!confirming && (
<div className="mt-3 flex gap-3">
<Button primary onClick={() => setConfirming(true)} disabled={busy || problems.length > 0 || lines.length === 0}>Zapisz</Button>
<Button onClick={onClose} disabled={busy}>Anuluj</Button>
</div>
)}
{confirming && (
<Confirm busy={busy} onConfirm={save} onBack={() => setConfirming(false)}>
<p>Zmieniam w falowniku:</p>
<ul className="list-disc pl-5 text-base">{lines.map((line) => <li key={line}>{line}</li>)}</ul>
</Confirm>
)}
</Card>
);
}
/** The schedule table of the Bateria view; with an "Edytuj" action when the gateway takes changes. */
export function Schedule({ b, onDone }: { b: Battery; onDone: (notice: Notice) => void }) {
const [editing, setEditing] = useState(false);
const off = b.mode ? !b.mode.touEnabled : false;
if (editing && b.editable && b.reserve && b.mode) {
return <ScheduleEditor b={b} onClose={() => setEditing(false)} onDone={onDone} />;
}
const edit = b.editable && b.reserve ? <Button onClick={() => setEditing(true)}>Edytuj</Button> : null;
return (
<Card title="Harmonogram" aside={<div className="flex items-center gap-2">{off && <Pill tone="off">wyłączony</Pill>}{edit}</div>}>
<table className={`w-full text-[0.95rem] ${off ? "opacity-50" : ""}`}>
<caption className="sr-only">Przedziały pracy baterii{off ? " (wyłączone)" : ""}</caption>
<thead>
<tr className="text-left text-sm text-soft">
<th scope="col" className="pb-2 font-medium">Godziny</th>
<th scope="col" className="pb-2 font-medium">Poziom</th>
<th scope="col" className="pb-2 text-right font-medium">Ładowanie z sieci</th>
</tr>
</thead>
<tbody>
{b.schedule.map((slot) => (
<tr key={slot.from} className="border-t border-edge">
<th scope="row" className="py-2 text-left font-semibold tabular-nums">{slot.from}–{slot.to}</th>
<td className="py-2 tabular-nums">{percent(slot.socPct)}</td>
<td className="py-2 text-right font-semibold">{slot.gridCharge ? "tak" : "nie"}</td>
</tr>
))}
</tbody>
</table>
</Card>
);
}
+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("— → 22 °C");
expect(within(rows[3]).getByText("odrzucona")).toBeInTheDocument();
expect(within(rows[0]).queryByText(/nieudana|odrzucona|konflikt|nieważna/)).toBeNull();
});
it("shows a clock time as HH:MM", async () => {
serve({ "api/audit": { entries: [entry({})] } });
const ChangeHistory = await history();
render(<ChangeHistory />);
expect(await screen.findByRole("listitem")).toHaveTextContent("18:00 → 21:30");
});
it("says when nothing changed", async () => {
serve({ "api/audit": { entries: [] } });
const ChangeHistory = await history();
render(<ChangeHistory />);
expect(await screen.findByText("Brak zmian w ostatnich 7 dniach")).toBeInTheDocument();
});
it("shows 20 changes and the rest on request", async () => {
const entries = Array.from({ length: 25 }, (_, i) => entry({ time: `2026-10-23T${String(i).padStart(2, "0")}:00:00Z` }));
serve({ "api/audit": { entries } });
const ChangeHistory = await history();
render(<ChangeHistory />);
expect(await screen.findAllByRole("listitem")).toHaveLength(20);
await userEvent.click(screen.getByRole("button", { name: "Pokaż więcej (5)" }));
expect(screen.getAllByRole("listitem")).toHaveLength(25);
expect(screen.queryByRole("button", { name: /Pokaż więcej/ })).toBeNull();
});
it("says the history is unavailable when the backend does not answer", async () => {
serve({});
const ChangeHistory = await history();
render(<ChangeHistory />);
expect(await screen.findByText("Historia chwilowo niedostępna.")).toBeInTheDocument();
});
});
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff. Show more