Compare commits

...
Author SHA1 Message Date
rootandClaude Opus 5.5 3c4386cee7 docs(roadmap): plan M2 and match what runs in production
CI / Check (gateway, backend, frontend) (push) Failing after 1m13s
The roadmap still said commands and battery commands were off, while both
have been enabled on the Pi since 2026-10-24 and the web UI is live. M2 gets
a checklist from the open questions in docs/frisko/; the F3 limits and the
4226 numbering are already settled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015x6FWeKCY59JfFJmzAm4TL
2026-10-25 14:29:31 +01:00
root 326db85114 Merge PR #106: fix(frontend): Deye PV in the inverter list adds only the string powers
CI / Check (gateway, backend, frontend) (push) Successful in 1m9s
Release / Image (backend) (push) Successful in 1m8s
Release / Image (frontend) (push) Successful in 50s
Release / Image (gateway) (push) Successful in 29s
2026-10-25 09:46:30 +01:00
rootandClaude Opus 5.5 ead74ebdec fix(frontend): Deye PV in the inverter list adds only the string powers
CI / Check (gateway, backend, frontend) (push) Successful in 1m12s
The summary summed every item of the Panele PV group, including each
string's voltage and current, so 5.9 kW of PV showed as about 7 kW.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:44:43 +01:00
root f99ae01626 Merge PR #105: fix: house consumption from the energy balance, Deye load is only its backup output
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
2026-10-25 09:40:13 +01:00
rootandClaude Opus 5.5 a804923290 fix(energy): house consumption as a balance, not the Deye backup output
CI / Check (gateway, backend, frontend) (push) Successful in 1m11s
The Deye load registers cover only the inverter's backup output, so the
house showed 0 W while it drew 300 W. Now power is PV + battery + grid
(house meter, Deye grid as fallback) and today's energy is the day
counters balance. The backup output is exposed as backupOutputW.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:35:30 +01:00
root cc0495fc46 Merge PR #104: refactor: remove the CWU heater, the house has none
CI / Check (gateway, backend, frontend) (push) Successful in 1m9s
Release / Image (backend) (push) Successful in 1m8s
Release / Image (frontend) (push) Successful in 54s
Release / Image (gateway) (push) Successful in 29s
2026-10-25 09:23:10 +01:00
root b3fb7f8b40 Merge PR #103: feat: choose temperature and hours for Ciepła woda teraz
CI / Check (gateway, backend, frontend) (push) Successful in 1m10s
2026-10-25 09:20:53 +01:00
rootandClaude Opus 5.5 33aead7352 refactor: remove the CWU heater, the house has none
CI / Check (gateway, backend, frontend) (push) Successful in 1m8s
The app showed the controller's heater signal as a device, a detail and a
statistic. Drop it from the API (heaterOn, heaterHours) and the UI; the
gateway still records output_heater as raw data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:20:35 +01:00
rootandClaude Opus 5.5 ee13c00bbb fix(frontend): short hour labels so the buttons do not wrap
CI / Check (gateway, backend, frontend) (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:18:44 +01:00
rootandClaude Opus 5.5 2e94cc1429 feat(frontend): choose temperature and hours for Ciepła woda teraz
The form reuses the trip form's steppers; the pump alone reaches about
47-48 degrees, so a higher choice gets a hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:17:12 +01:00
rootandClaude Opus 5.5 0895125e9c feat(presets): choose the hours of a timed preset
POST /api/presets/{id} takes an optional whole number of hours (1-4) for a
preset whose changes are timed; anything else is 400 invalid_hours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:14:40 +01:00
root 36bee5ca08 Merge PR #102: docs(frisko): compressor and CWU parameters read on 2026-10-25
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
2026-10-25 08:49:35 +01:00
rootandClaude Opus 5.5 fa678473e3 docs(frisko): compressor and CWU parameters read on 2026-10-25
CI / Check (gateway, backend, frontend) (push) Successful in 2m12s
A one-off read-only FC3 of 4146-4166 and 4246-4247 between two gateway
polls. 4166 = 20 min explains the stage-2 switch seen in history and
4162 = 5 min the five-minute restart after a TmaxPc stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:46:46 +01:00
root b46d820fb2 Merge PR #101: docs(frisko): the +20 min meter step is the compressor's second stage
CI / Check (gateway, backend, frontend) (push) Failing after 1m11s
2026-10-25 08:33:28 +01:00
rootandClaude Opus 5.5 f61c25f449 docs(frisko): the +20 min meter step is the compressor's second stage
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
The owner says the CWU heater is not connected; the per-phase meter data
show the step splits evenly over the three phases while stage 4114 goes
from 33 to 66, so it is the compressor, not a heater. At an outlet of
52 C (TmaxPc) the compressor stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:31:26 +01:00
root eec29666eb Merge PR #100: fix: the CWU heater is not connected, show its output as a controller signal
CI / Check (gateway, backend, frontend) (push) Failing after 1m15s
2026-10-25 08:25:16 +01:00
rootandClaude Opus 5.5 6d9e92ee22 docs(frisko): the CWU heater is not connected, its output is only a signal
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
The meter step at +20 min of a CWU cycle stays unexplained; list the hypotheses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:22:53 +01:00
rootandClaude Opus 5.5 bb22afcd35 fix(frontend): show the heater output as a controller signal, not as power
The electric CWU heater is not connected (owner, 2026-10-25), so the amber
warning, the red chip and glow and the expensive-power hint were wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:22:10 +01:00
root ae9376b24d Merge PR #99: feat: active periods, heater warning and the weekly program from history
CI / Check (gateway, backend, frontend) (push) Successful in 1m12s
2026-10-25 00:22:45 +02:00
rootandClaude Opus 5.5 31b668f313 feat(frontend): period, heater warning and weekly program from history
CI / Check (gateway, backend, frontend) (push) Successful in 1m13s
Overview shows comfort or economy under the water target and in the room
details, and an amber warning while the electric heater runs. Ustawienia
says what Ciepła woda teraz does during a comfort period. Historia gets a
Program view with the intervals the history shows per weekday.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:18:56 +02:00
rootandClaude Opus 5.5 36875dd103 feat(backend): active period, heater state and weekly program from history
The heat pump view says whether CWU and room are in comfort or economy,
from the target compared with the setpoints (valid with program 1 only).
GET /api/program reads the comfort intervals per weekday from the polls
instead of asking the controller, whose program registers need writes.
Adds the history analysis of 2026-10 to docs/frisko.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:16:38 +02:00
root c8ada0e6a8 Merge PR #98: docs: ADR-016, the web UI is on the internet behind the sign-in
CI / Check (gateway, backend, frontend) (push) Failing after 1m9s
2026-10-24 23:43:32 +02:00
rootandClaude Opus 5.5 0e3a77047b test(frontend): allow 20 s per test on a busy CI runner
CI / Check (gateway, backend, frontend) (push) Successful in 1m11s
The parallel check.sh builds the gateway and backend while the frontend
tests run; on the shared runner the first render of a file took over the
5 s default, and the timed-out test left its DOM for the next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:41:35 +02:00
rootandClaude Opus 5.5 93fedb198c docs: ADR-016, the web UI is on the internet behind the sign-in
CI / Check (gateway, backend, frontend) (push) Failing after 1m12s
ADR-009 kept the UI on the Krosno network without a login. With the
sign-in of ADR-014 the app is open to the internet; only first-account
setup keeps the two-homes allowlist in Traefik.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:39:14 +02:00
root b69beadd76 Merge PR #96: feat(frontend): login, first-account setup and accounts
CI / Check (gateway, backend, frontend) (push) Successful in 1m7s
Release / Image (backend) (push) Successful in 1m10s
Release / Image (frontend) (push) Successful in 51s
Release / Image (gateway) (push) Successful in 30s
2026-10-24 23:16:59 +02:00
root 62d61af5dd Merge PR #97: feat(backend): sign-in with local accounts and Keycloak (ADR-014) 2026-10-24 23:16:59 +02:00
rootandClaude Opus 5.5 4e8ffc2ec3 feat(backend): first-account setup and account management in the app
CI / Check (gateway, backend, frontend) (push) Successful in 1m8s
With USERS_FILE set but empty or missing the backend starts in setup
mode and the first visitor creates the account, once, under a lock.
Signed-in users list, add and remove accounts and change their own
password; removed accounts lose their sessions at once. USERS_FILE is
rewritten atomically (temp, fsync, rename, mode 600).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:13:31 +02:00
rootandClaude Opus 5.5 eac21b17e2 test(frontend): keep sample passwords out of the secret scan
CI / Check (gateway, backend, frontend) (push) Successful in 53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:12:47 +02:00
rootandClaude Opus 5.5 73bbb1a0d7 feat(frontend): first-account setup screen and Konta card in Ustawienia
With config.setup the login form becomes "Utwórz pierwsze konto" (a 409
falls back to login). Ustawienia gets accounts: list, add, change own
password (local only), delete with confirmation, not the last one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:12:11 +02:00
rootandClaude Opus 5.5 f92307d9e1 fix(backend): atomic login throttle, revoked accounts, log hygiene
CI / Check (gateway, backend, frontend) (push) Successful in 55s
The throttle now counts an attempt before the password is hashed, so
parallel guesses cannot outrun it, and at most 4 hashes run at once.
A local session ends when its account leaves USERS_FILE. The provider's
error text is sanitized before logging, a missing OIDC_REQUIRED_ROLE is
warned about, OIDC discovery and keys are fetched outside the lock and
an unreadable JWK is skipped. Ops notes: nginx header, compose example,
/api/live for monitors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:04:49 +02:00
rootandClaude Opus 5.5 34b12881b9 fix(frontend): login edge cases from review (no-login server, odd oidc codes, 404)
CI / Check (gateway, backend, frontend) (push) Successful in 53s
Method "none" is signed in without Account or logout; a login screen with
no enabled way in offers a reload; unknown oidc error codes (__proto__)
no longer crash the page; a 404 from login says local login is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:03:02 +02:00
rootandClaude Opus 5.5 b7b26d83b2 test(backend): rename test key constants so the secret scan passes
CI / Check (gateway, backend, frontend) (push) Successful in 59s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:52:41 +02:00
rootandClaude Opus 5.5 24b7946df1 feat(backend): sign-in with local accounts and Keycloak (ADR-014)
The backend now authenticates itself: local accounts from USERS_FILE
(PBKDF2-SHA256, throttled) and OIDC code flow with PKCE, independent of
each other, so Keycloak being down never locks the family out. A signed
stateless cookie carries the session; its user replaces Remote-User as
the requester. Without any sign-in configured the backend refuses to
start unless AUTH_DISABLED=true. /api/live serves the healthcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:51:21 +02:00
rootandClaude Opus 5.5 16534ddcee feat(frontend): login screen with password and Keycloak, session expiry
CI / Check (gateway, backend, frontend) (push) Successful in 1m4s
AuthGate asks api/auth/me before the app starts; without a session the
login screen shows instead of the app and its pollers. The form is always
the way in when Keycloak is down. A 401 from any poller or write sends the
user back to login and, after it, to the same screen. Stan and the side
panel show the user and "Wyloguj".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:44:30 +02:00
100 changed files with 5781 additions and 261 deletions

No files matched your search

+3 -3
View File
@@ -15,8 +15,8 @@ USER 10001:10001
WORKDIR /app
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
EXPOSE 8080
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body),
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine.
# /api/live needs no session and does not look at the gateway, so it only tells whether the backend
# itself serves requests. busybox wget ships with alpine.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/health" || exit 1
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/live" || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
+75 -9
View File
@@ -3,6 +3,7 @@
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
`GET /api/energy` (example `src/test/resources/api/energy.json`) is the house electricity now. `housePowerW` is the whole house by balance, because the Deye `load` registers cover only its backup output (`backupOutputW`): Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, the grid being the house meter's `total_power_active` when live, else the Deye `grid_power_w`; floored at 0, null when any of these readings is missing or stale. `today.houseKwh` is Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged (Deye day counters), null when any is missing.
`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)
@@ -10,9 +11,16 @@ 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
`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/program?days=14` (7 to 30, default 14; 400 otherwise, 502 when InfluxDB is down; example `src/test/resources/api/program.json`) is the weekly
program as the history shows it, not read from the controller: `weekdays[]` from Monday (`day` 1) to Sunday, each with `cwu` and `room`:
`comfort` (intervals `from`/`to`, local "HH:MM", "24:00" at midnight), `observedDays`, `agreeingDays` and `confidence` (`confirmed`: at least
two days of that weekday agree within 5 minutes, `seen_once`, `no_data`). A boundary is the middle between the two polls where the period
changes (`Periods`), rounded to 5 minutes; an override, ambiguous setpoints (e.g. during our writes) or a gap over 600 s drop the interval.
Today is left out. `/api/heat-pump` has `periods` (`cwu`, `room`: `comfort`, `economy`, `unknown`, `override`; `program`, `programText`).
`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`):
@@ -23,7 +31,7 @@ The settings come from the gateway's slower `deye_settings` reading and are stal
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
Same Origin, header and JSON rules as the other writes; the signed-in 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).
@@ -31,7 +39,7 @@ inverter holds), 400 bad body, 503 gateway unreachable or without battery comman
`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).
`HHMM`, `0/1`), `who` (the signed-in user; 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`,
@@ -43,7 +51,7 @@ when a refresh fails the older answer is served with `stale` true. 503 only when
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
without the two URLs.
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`.
Setpoint changes go only to the gateway; the signed-in user is sent as `X-Requested-By`.
| Variable | Default | Meaning |
|---|---|---|
@@ -55,21 +63,80 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast |
| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count |
| `USERS_FILE` | unset | local accounts, see Sign-in; outside the image like `PRESETS_FILE` |
| `OIDC_ISSUER` | unset | Keycloak realm URL, e.g. `https://login.mariuszpacek.pl/realms/home`; unset turns OIDC off |
| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | required with `OIDC_ISSUER` | the confidential client; redirect `https://pompa.mariuszpacek.pl/api/auth/oidc/callback` |
| `OIDC_REQUIRED_ROLE` | unset | a role the user must have: realm role (`realm_access.roles`) or role of this client (`resource_access.<client>.roles`) in the ID token |
| `SESSION_SECRET` | required with `USERS_FILE` or `OIDC_ISSUER` | at least 32 bytes signing the cookies, e.g. `openssl rand -base64 48`; changing it signs everyone out |
| `SESSION_DAYS` | `30` | how long a session lasts |
| `TRUSTED_PROXY_HOPS` | `1` | proxies that append to `X-Forwarded-For` before the backend (Traefik: 1), for the login throttle |
| `AUTH_DISABLED` | unset | `true` runs without sign-in (every request is `phone`), for development; refused together with `USERS_FILE` or `OIDC_ISSUER` |
| `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
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example AUTH_DISABLED=true \
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
```
## Sign-in (ADR-014)
Without `USERS_FILE` or `OIDC_ISSUER` the backend refuses to start unless `AUTH_DISABLED=true`. The two ways in are
independent: local accounts keep working when Keycloak is down, and the backend never waits for Keycloak at start
(discovery and keys are fetched at the first sign-in and kept an hour). Every endpoint but those below answers
`401 {"error":"unauthenticated"}` without a valid session; the writes keep their Origin, header and JSON rules.
| Endpoint | Answer |
|---|---|
| `GET /api/auth/config` | `{"local", "oidc", "setup"}` booleans (`auth-config.json`); `setup` is true while `USERS_FILE` has no account |
| `POST /api/auth/setup` `{"username","password"}` | first account, once: 200 `{"user","method":"local"}` and the cookie; 409 `{"error":"setup_done"}` once any account exists; 400 `weak_password` (under 10 characters) or `invalid_username`; 404 without `USERS_FILE` |
| `GET /api/auth/users` | session needed: `[{"username"}]` |
| `POST /api/auth/users` `{"username","password"}` | session needed: adds a local account; 200 `{"username"}`, 409 `exists`, 400 as setup |
| `POST /api/auth/password` `{"current","new"}` | session needed, local sessions only (403 `local_only` otherwise): 204; 401 `invalid` (counts toward the throttle), 429, 400 `weak_password` |
| `POST /api/auth/users/{name}/delete` | session needed: 204 (removing yourself also clears your cookie); 404 `unknown_user`; 409 `last_user` |
| `POST /api/auth/login` `{"username","password"}` | 200 `{"user","method":"local"}` and the cookie; 401 `{"error":"invalid"}`; 429 `{"error":"throttled","retryAfterSeconds"}` with `Retry-After`; 404 without `USERS_FILE` |
| `POST /api/auth/logout` | 204, cookie cleared |
| `GET /api/auth/me` | 200 `{"user","method"}` (`auth-me.json`; `method` is `local`, `oidc`, or `none` with `AUTH_DISABLED`) or 401 |
| `GET /api/auth/oidc/start` | 302 to Keycloak; 404 without OIDC |
| `GET /api/auth/oidc/callback` | 302 to `/`, or to `/#login?error=oidc_unavailable` (Keycloak down), `oidc_denied` (refused or role missing), `oidc_failed` (bad state, token or code) |
| `GET /api/live` | 200 `ok`, for the container healthcheck and external monitors: `/api/health` needs a session now |
The session is a cookie `hp_session` (HttpOnly, Secure, SameSite=Lax, Path=/) signed with HMAC-SHA256 and holding user, method,
issue time and expiry; there is no server state, so a stolen cookie lives until it expires or the secret changes; a `local` session also ends when its account
is removed (it is checked on every request).
The OIDC flow is authorization code with PKCE S256, state and nonce, in a 10 minute signed cookie `hp_oidc`; the ID token is
verified here (RS256 against the JWKS key by `kid`, `iss`, `aud` and `azp`, `exp`/`iat` with 60 s skew, `nonce`).
The user name is `preferred_username`, else `email`, else `sub`.
**Local accounts**: `USERS_FILE` is JSON `[{"username": "tata", "passwordHash": "pbkdf2-sha256$600000$<salt>$<hash>"}]`, a username of
1 to 64 of `A-Za-z0-9_.@+-`, a bad file stops the start. The app edits the file: it rewrites it atomically (temp file in the same directory, fsync, rename, mode 600), so mount a **directory** (`./auth:/config/auth`, `USERS_FILE=/config/auth/users.json`) that uid 10001 owns: `chown 10001:10001 auth`. A missing file or one with no accounts is setup mode: the first visitor to the app creates the first account in the app (accepted risk, ADR-014: Traefik admits only the two home networks). Add, remove accounts and change a password in the app; each change is logged (who, what, never a password). A hash can still be made by hand, without echoing the password (asked twice on a terminal;
a pipe reads one line):
```bash
docker run --rm -it git.mariuszpacek.pl/mariusz.pacek/home-pump-backend:latest hash-password
java -jar backend/target/home-pump-backend-1.0.0.jar hash-password
```
Five login attempts in 15 minutes block that username and, separately, that client address (429, `Retry-After`; an attempt is
counted before the password is checked, so parallel guesses cannot outrun it, and a success clears the username); a blocked
username is refused even with the right password. At most 4 passwords are hashed at once, more get 429 at once. An unknown username costs one password hash like a wrong password.
The address is the entry `TRUSTED_PROXY_HOPS` from the right of `X-Forwarded-For` (Traefik appends the address it saw;
what a client put in front is ignored); without it the socket's address.
**Keycloak client** in the realm of `OIDC_ISSUER`: Client authentication on (confidential), only Standard flow, PKCE method
`S256`, valid redirect URI `https://pompa.mariuszpacek.pl/api/auth/oidc/callback`, web origin `https://pompa.mariuszpacek.pl`.
Its secret is `OIDC_CLIENT_SECRET`. Without `OIDC_REQUIRED_ROLE`, every user of the realm can sign in; the backend logs a warning at start. For `OIDC_REQUIRED_ROLE`, the client scope `roles` must put the roles into the ID token
(its realm and client role mappers: "Add to ID token" on).
`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
(from `/commands`), checked before anything is sent. A timed preset (built-in "Ciepła woda teraz") also takes optional
`"hours"` (example `preset-cwu-now-request.json`): a whole number from 1 to 4 replacing its duration; anything else, or
hours for a held preset, is 400 `{"error":"invalid_hours"}`. 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/refresh` (same Origin, header and JSON rules; send `{}`) is the "Odśwież" button: it asks the gateway for one
@@ -83,9 +150,8 @@ pending change of that setpoint now and answers with the gateway's verdict, exam
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).
Publish no host port in production: only the frontend, behind Traefik, may reach the backend, so Traefik's
access rules (ADR-009) stay in front of the sign-in.
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
+7
View File
@@ -9,6 +9,13 @@ services:
env_file: .env
environment:
TZ: Europe/Warsaw
# Sign-in (README): the accounts directory, writable by uid 10001 (chown 10001:10001 auth); the app rewrites users.json in it. In .env, for example:
# USERS_FILE=/config/auth/users.json SESSION_SECRET=<openssl rand -base64 48>
# OIDC_ISSUER=https://login.mariuszpacek.pl/realms/home OIDC_CLIENT_ID=home-pump
# OIDC_CLIENT_SECRET=<from Keycloak> OIDC_REQUIRED_ROLE=family
# OIDC_REDIRECT_URI=https://pompa.mariuszpacek.pl/api/auth/oidc/callback
volumes:
- ./auth:/config/auth
networks: [home-pump]
networks:
@@ -1,5 +1,6 @@
package home.backend;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.config.PresetFile;
import home.backend.adapters.gateway.GatewayHttpClient;
import home.backend.adapters.gateway.GatewayHttpCommands;
@@ -18,6 +19,7 @@ import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.ProgramService;
import home.backend.application.StatsService;
import home.backend.application.LeaseRenewer;
import home.backend.application.PiSensorService;
@@ -43,7 +45,7 @@ final class Backend {
/**
* Binds the port but does not start serving.
*
* @throws IllegalArgumentException when PRESETS_FILE is unusable
* @throws IllegalArgumentException when PRESETS_FILE or USERS_FILE is unusable
*/
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
@@ -59,7 +61,9 @@ final class Backend {
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), commands);
new ProgramService(influx, clock),
weather(influxSettings, clock), commands,
Authentication.create(settings.auth(), clock));
}
private static WeatherService weather(InfluxSettings influx, Clock clock) {
@@ -2,6 +2,8 @@ package home.backend;
import home.backend.adapters.http.ApiServer;
import home.backend.adapters.auth.PasswordHashCli;
import java.io.IOException;
import java.time.Clock;
@@ -11,6 +13,9 @@ public final class Main {
}
public static void main(String[] args) throws IOException {
if (args.length == 1 && args[0].equals("hash-password")) {
System.exit(PasswordHashCli.run(System.console(), System.in, System.out, System.err));
}
Settings settings;
ApiServer server;
try {
@@ -21,6 +26,7 @@ public final class Main {
System.exit(2);
return;
}
settings.auth().warnings().forEach(w -> System.err.println("home-pump-backend: WARNING: " + w));
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
if (settings.gatewayToken() == null) {
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
@@ -1,6 +1,10 @@
package home.backend;
import home.backend.adapters.auth.AuthSettings;
import home.backend.adapters.auth.OidcSettings;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
@@ -8,7 +12,7 @@ import java.util.Optional;
/** Twelve-factor settings: everything comes from environment variables. */
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
String gatewayToken, Path presetsFile) {
String gatewayToken, Path presetsFile, AuthSettings auth) {
public static Settings fromEnvironment(Map<String, String> env) {
return new Settings(
@@ -18,7 +22,44 @@ public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gateway
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
optional(env, "GATEWAY_TOKEN"),
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null));
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null),
auth(env));
}
private static final int MIN_SECRET_BYTES = 32;
/** Refuses to start open by accident: sign-in is configured, or AUTH_DISABLED=true says it is meant. */
private static AuthSettings auth(Map<String, String> env) {
Path usersFile = Optional.ofNullable(optional(env, "USERS_FILE")).map(Path::of).orElse(null);
OidcSettings oidc = optional(env, "OIDC_ISSUER") == null ? null : oidc(env);
String secret = optional(env, "SESSION_SECRET");
boolean disabled = "true".equals(optional(env, "AUTH_DISABLED"));
int days = (int) number(env, "SESSION_DAYS", 30, 1, 365);
int hops = (int) number(env, "TRUSTED_PROXY_HOPS", 1, 1, 10);
boolean configured = usersFile != null || oidc != null;
if (disabled && configured) {
throw new IllegalArgumentException("AUTH_DISABLED=true contradicts USERS_FILE and OIDC_ISSUER: unset one");
}
if (!disabled && !configured) {
throw new IllegalArgumentException("no sign-in configured: set USERS_FILE and/or OIDC_ISSUER, "
+ "or AUTH_DISABLED=true for development only");
}
if (configured && (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < MIN_SECRET_BYTES)) {
throw new IllegalArgumentException("SESSION_SECRET is required and must be at least " + MIN_SECRET_BYTES
+ " bytes, e.g. from: openssl rand -base64 48");
}
return new AuthSettings(usersFile, oidc, secret, days, disabled, hops);
}
private static OidcSettings oidc(Map<String, String> env) {
for (String name : new String[] {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI"}) {
if (optional(env, name) == null) {
throw new IllegalArgumentException(name + " is required when OIDC_ISSUER is set");
}
}
return new OidcSettings(URI.create(optional(env, "OIDC_ISSUER")), optional(env, "OIDC_CLIENT_ID"),
optional(env, "OIDC_CLIENT_SECRET"), URI.create(optional(env, "OIDC_REDIRECT_URI")),
optional(env, "OIDC_REQUIRED_ROLE"));
}
/** Null when unset or blank. */
@@ -0,0 +1,20 @@
package home.backend.adapters.auth;
import java.nio.file.Path;
import java.util.List;
/**
* How users sign in. At least one of usersFile and oidc, or disabled; the secret signs the cookies.
* proxyHops is how many proxies append to X-Forwarded-For before the backend (Traefik: 1).
*/
public record AuthSettings(Path usersFile, OidcSettings oidc, String sessionSecret, int sessionDays,
boolean disabled, int proxyHops) {
/** Things worth saying at startup. */
public List<String> warnings() {
if (oidc != null && oidc.requiredRole() == null) {
return List.of("OIDC_REQUIRED_ROLE is not set: every user of the Keycloak realm can sign in");
}
return List.of();
}
}
@@ -0,0 +1,166 @@
package home.backend.adapters.auth;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
import java.util.concurrent.Semaphore;
/** Who may use the API: local accounts and OIDC, independent of each other, both ending in a session cookie. */
public final class Authentication {
public static final String LOCAL = "local";
public enum Status { OK, INVALID, THROTTLED }
public record Login(Status status, long retryAfterSeconds) {
}
private static final int MAX_FAILURES = 5;
private static final Duration THROTTLE_WINDOW = Duration.ofMinutes(15);
/** Password hashing is slow on purpose; a flood must not queue up behind it. */
private static final int PARALLEL_HASHES = 4;
private static final int MIN_PASSWORD = 10;
private static final long BUSY_RETRY_SECONDS = 1;
private final Sessions sessions;
private final UserStore users;
private final LoginThrottle throttle;
private final OidcFlow oidc;
private final int proxyHops;
private final Semaphore hashing = new Semaphore(PARALLEL_HASHES);
private Authentication(Sessions sessions, UserStore users, LoginThrottle throttle, OidcFlow oidc, int proxyHops) {
this.sessions = sessions;
this.users = users;
this.throttle = throttle;
this.oidc = oidc;
this.proxyHops = proxyHops;
}
/** @throws IllegalArgumentException when USERS_FILE is unusable */
public static Authentication create(AuthSettings settings, Clock clock) {
if (settings.disabled()) {
return new Authentication(null, null, null, null, settings.proxyHops());
}
var tokens = new SignedTokens(settings.sessionSecret().getBytes(StandardCharsets.UTF_8), clock);
return new Authentication(new Sessions(tokens, clock, Duration.ofDays(settings.sessionDays())),
settings.usersFile() == null ? null : UserStore.load(settings.usersFile()),
new LoginThrottle(clock, MAX_FAILURES, THROTTLE_WINDOW),
settings.oidc() == null ? null : new OidcFlow(settings.oidc(), tokens, clock),
settings.proxyHops());
}
/** For development and tests: every request is the user "phone". */
public static Authentication disabled() {
return new Authentication(null, null, null, null, 1);
}
public boolean localEnabled() {
return users != null;
}
public boolean oidcEnabled() {
return oidc != null;
}
public OidcFlow oidc() {
return oidc;
}
public int proxyHops() {
return proxyHops;
}
public Optional<Session> session(String cookieHeader) {
if (sessions == null) {
return Optional.of(Session.OPEN);
}
// A removed account loses its sessions at once; OIDC sessions end with their expiry.
return sessions.read(cookieHeader).filter(s -> !LOCAL.equals(s.method()) || users != null && users.exists(s.user()));
}
/** Checks the password unless the user or the address is blocked; a blocked one learns nothing about it. */
public Login login(String username, char[] password, String clientIp) {
String userKey = "user:" + username.toLowerCase(Locale.ROOT);
String ipKey = "ip:" + clientIp;
long wait = throttle.tryAcquire(userKey, ipKey);
if (wait > 0) {
return new Login(Status.THROTTLED, wait);
}
if (!hashing.tryAcquire()) {
throttle.release(userKey, ipKey);
return new Login(Status.THROTTLED, BUSY_RETRY_SECONDS);
}
try {
if (users.authenticate(username, password)) {
throttle.cleared(userKey);
throttle.release(ipKey);
return new Login(Status.OK, 0);
}
return new Login(Status.INVALID, 0);
} finally {
hashing.release();
}
}
public boolean setupNeeded() {
return users != null && users.isEmpty();
}
public static boolean strongEnough(String password) {
return password.length() >= MIN_PASSWORD;
}
/** Creates the first account, once: OK for the winner, INVALID (setup is done) for everyone after. */
public Login setup(String username, char[] password, String clientIp) {
String ipKey = "ip:" + clientIp;
long wait = throttle.tryAcquire(ipKey);
if (wait > 0) {
return new Login(Status.THROTTLED, wait);
}
if (!users.addFirst(username, PasswordHash.create(password))) {
return new Login(Status.INVALID, 0);
}
System.err.println("home-pump-backend: setup created the first account " + username);
throttle.release(ipKey);
return new Login(Status.OK, 0);
}
public java.util.List<String> usernames() {
return users.usernames();
}
/** @return false when the name is taken */
public boolean addUser(String by, String username, char[] password) {
boolean added = users.add(username, PasswordHash.create(password));
System.err.println("home-pump-backend: " + by + " added account " + username + (added ? "" : " (refused, exists)"));
return added;
}
/** Checks the current password like a login does (throttled), then sets the new one. */
public Login changePassword(String username, char[] current, char[] next, String clientIp) {
Login login = login(username, current, clientIp);
if (login.status() == Status.OK) {
users.changePassword(username, PasswordHash.create(next));
System.err.println("home-pump-backend: " + username + " changed their password");
}
return login;
}
public UserStore.Removal removeUser(String by, String username) {
var removal = users.remove(username);
System.err.println("home-pump-backend: " + by + " removed account " + username + ": " + removal);
return removal;
}
/** The Set-Cookie header value that signs the user in. */
public String sessionCookie(String user, String method) {
return sessions.setCookie(user, method);
}
public String clearedSessionCookie() {
return sessions == null ? "" : sessions.clearCookie();
}
}
@@ -0,0 +1,18 @@
package home.backend.adapters.auth;
import java.util.Base64;
/** Unpadded base64url, as tokens and PKCE use it. */
final class Base64Url {
private Base64Url() {
}
static String encode(byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static byte[] decode(String text) {
return Base64.getUrlDecoder().decode(text);
}
}
@@ -0,0 +1,36 @@
package home.backend.adapters.auth;
import java.time.Duration;
import java.util.Optional;
/** Reads the Cookie header and builds Set-Cookie headers with the flags every cookie here carries. */
final class Cookies {
private Cookies() {
}
static Optional<String> read(String header, String name) {
if (header == null) {
return Optional.empty();
}
for (String pair : header.split(";")) {
String[] kv = pair.trim().split("=", 2);
if (kv.length == 2 && kv[0].equals(name)) {
return Optional.of(kv[1]);
}
}
return Optional.empty();
}
static String set(String name, String value, Duration maxAge, String path) {
return name + "=" + value + "; Max-Age=" + maxAge.toSeconds() + attributes(path);
}
static String clear(String name, String path) {
return name + "=; Max-Age=0" + attributes(path);
}
private static String attributes(String path) {
return "; Path=" + path + "; HttpOnly; Secure; SameSite=Lax";
}
}
@@ -0,0 +1,143 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.Signature;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
/** Checks an ID token with the JDK alone: RS256 signature, issuer, audience, times, nonce and the required role. */
final class IdTokenVerifier {
/** The signing keys of the identity provider. */
interface Keys {
PublicKey key(String kid) throws OidcException;
}
private static final ObjectMapper JSON = new ObjectMapper();
private static final Duration SKEW = Duration.ofSeconds(60);
private final OidcSettings settings;
private final Clock clock;
IdTokenVerifier(OidcSettings settings, Clock clock) {
this.settings = settings;
this.clock = clock;
}
/** @return the user name of the token */
String verify(String idToken, String nonce, Keys keys) throws OidcException {
String[] parts = idToken.split("\\.", -1);
if (parts.length != 3) {
throw new OidcException(FAILED, "the ID token is not a JWT");
}
JsonNode header = json(parts[0]);
if (!"RS256".equals(header.path("alg").asText())) {
throw new OidcException(FAILED, "the ID token is not signed with RS256");
}
verifySignature(parts[0] + "." + parts[1], parts[2], keys.key(header.path("kid").asText()));
JsonNode claims = json(parts[1]);
checkClaims(claims, nonce);
checkRole(claims);
return userName(claims);
}
private void checkClaims(JsonNode claims, String nonce) throws OidcException {
long now = clock.instant().getEpochSecond();
if (!settings.issuerBase().equals(claims.path("iss").asText("").replaceAll("/+$", ""))) {
throw new OidcException(FAILED, "wrong issuer");
}
if (!audiences(claims).contains(settings.clientId())) {
throw new OidcException(FAILED, "the token is for another client");
}
if (audiences(claims).size() > 1 && !settings.clientId().equals(claims.path("azp").asText())) {
throw new OidcException(FAILED, "wrong authorized party");
}
if (!claims.path("exp").canConvertToLong() || claims.get("exp").asLong() + SKEW.toSeconds() <= now) {
throw new OidcException(FAILED, "the token has expired");
}
if (!claims.path("iat").canConvertToLong() || claims.get("iat").asLong() - SKEW.toSeconds() > now) {
throw new OidcException(FAILED, "the token is issued in the future");
}
boolean sameNonce = MessageDigest.isEqual(nonce.getBytes(StandardCharsets.UTF_8),
claims.path("nonce").asText("").getBytes(StandardCharsets.UTF_8));
if (!sameNonce) {
throw new OidcException(FAILED, "wrong nonce");
}
}
/** A realm role or a role of this client, as Keycloak puts them in {@code realm_access} and {@code resource_access}. */
private void checkRole(JsonNode claims) throws OidcException {
String role = settings.requiredRole();
if (role == null) {
return;
}
boolean has = contains(claims.path("realm_access").path("roles"), role)
|| contains(claims.path("resource_access").path(settings.clientId()).path("roles"), role);
if (!has) {
throw new OidcException(DENIED, "the user lacks the role " + role);
}
}
private String userName(JsonNode claims) throws OidcException {
for (String claim : new String[] {"preferred_username", "email", "sub"}) {
String name = claims.path(claim).asText("");
if (Session.isValidUser(name)) {
return name;
}
}
throw new OidcException(FAILED, "the token has no usable user name");
}
private static List<String> audiences(JsonNode claims) {
JsonNode aud = claims.path("aud");
var all = new ArrayList<String>();
if (aud.isArray()) {
aud.forEach(a -> all.add(a.asText()));
} else if (aud.isTextual()) {
all.add(aud.asText());
}
return all;
}
private static boolean contains(JsonNode array, String value) {
for (JsonNode item : array) {
if (value.equals(item.asText())) {
return true;
}
}
return false;
}
private static void verifySignature(String signedPart, String signature, PublicKey key) throws OidcException {
try {
Signature rsa = Signature.getInstance("SHA256withRSA");
rsa.initVerify(key);
rsa.update(signedPart.getBytes(StandardCharsets.US_ASCII));
if (!rsa.verify(Base64Url.decode(signature))) {
throw new OidcException(FAILED, "the ID token signature is wrong");
}
} catch (GeneralSecurityException | IllegalArgumentException e) {
throw new OidcException(FAILED, "the ID token signature cannot be checked", e);
}
}
private static JsonNode json(String part) throws OidcException {
try {
return JSON.readTree(Base64Url.decode(part));
} catch (IOException | IllegalArgumentException e) {
throw new OidcException(FAILED, "the ID token is malformed", e);
}
}
}
@@ -0,0 +1,70 @@
package home.backend.adapters.auth;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Map;
/** Blocks a key (a username or a client address) after too many failures within a window. */
public final class LoginThrottle {
private final Clock clock;
private final int maxFailures;
private final Duration window;
/** The newest failures of each key, at most {@code maxFailures}, oldest first. */
private final Map<String, ArrayDeque<Instant>> failures = new HashMap<>();
public LoginThrottle(Clock clock, int maxFailures, Duration window) {
this.clock = clock;
this.maxFailures = maxFailures;
this.window = window;
}
/**
* Counts an attempt against every key before the password is checked, so parallel guesses cannot
* outrun the limit; {@link #release} takes it back.
*
* @return 0 when the attempt may go on, else the seconds until the most blocked key may try again
*/
public synchronized long tryAcquire(String... keys) {
Instant now = clock.instant();
long wait = 0;
for (String key : keys) {
var times = failures.get(key);
if (times != null && times.size() >= maxFailures && times.peekFirst().plus(window).isAfter(now)) {
wait = Math.max(wait, Duration.between(now, times.peekFirst().plus(window)).toSeconds() + 1);
}
}
if (wait > 0) {
return wait;
}
failures.values().removeIf(times -> !times.peekLast().plus(window).isAfter(now));
for (String key : keys) {
var times = failures.computeIfAbsent(key, k -> new ArrayDeque<>());
times.addLast(now);
if (times.size() > maxFailures) {
times.removeFirst();
}
}
return 0;
}
/** Takes back the newest attempt of each key. */
public synchronized void release(String... keys) {
for (String key : keys) {
var times = failures.get(key);
if (times != null) {
times.pollLast();
if (times.isEmpty()) {
failures.remove(key);
}
}
}
}
public synchronized void cleared(String key) {
failures.remove(key);
}
}
@@ -0,0 +1,40 @@
package home.backend.adapters.auth;
/** Why an OIDC sign-in did not happen; the code is what the login screen is told. */
public final class OidcException extends Exception {
public enum Reason {
/** Keycloak did not answer. */
UNAVAILABLE("oidc_unavailable"),
/** The user said no, or lacks the required role. */
DENIED("oidc_denied"),
/** Anything else: bad state, a token that does not verify, a refused code. */
FAILED("oidc_failed");
private final String code;
Reason(String code) {
this.code = code;
}
public String code() {
return code;
}
}
private final Reason reason;
public OidcException(Reason reason, String message) {
super(message);
this.reason = reason;
}
public OidcException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
public Reason reason() {
return reason;
}
}
@@ -0,0 +1,113 @@
package home.backend.adapters.auth;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
/**
* Authorization code flow with PKCE (S256), state and nonce. What must survive the trip to the identity
* provider travels in a short-lived signed cookie, so the backend keeps no state.
*/
public final class OidcFlow {
/** Where to send the browser, and the cookie to set on that response. */
public record Start(URI location, String setCookie) {
}
private static final String COOKIE = "hp_oidc";
private static final String PURPOSE = "oidc-login";
private static final String PATH = "/api/auth/oidc";
private static final Duration LOGIN_TIME = Duration.ofMinutes(10);
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private static final SecureRandom RANDOM = new SecureRandom();
private final OidcSettings settings;
private final OidcProvider provider;
private final IdTokenVerifier verifier;
private final SignedTokens tokens;
OidcFlow(OidcSettings settings, SignedTokens tokens, Clock clock) {
this.settings = settings;
this.provider = new OidcProvider(settings, TIMEOUT, clock);
this.verifier = new IdTokenVerifier(settings, clock);
this.tokens = tokens;
}
/** @throws OidcException UNAVAILABLE when the identity provider does not answer */
public Start start() throws OidcException {
String state = random();
String nonce = random();
String verifierText = random();
Map<String, String> query = new LinkedHashMap<>();
query.put("response_type", "code");
query.put("client_id", settings.clientId());
query.put("redirect_uri", settings.redirectUri().toString());
query.put("scope", "openid profile");
query.put("state", state);
query.put("nonce", nonce);
query.put("code_challenge", challenge(verifierText));
query.put("code_challenge_method", "S256");
URI endpoint = provider.endpoints().authorization();
String separator = endpoint.getRawQuery() == null ? "?" : "&";
String cookie = Cookies.set(COOKIE, tokens.issue(PURPOSE, LOGIN_TIME,
Map.of("state", state, "nonce", nonce, "verifier", verifierText)), LOGIN_TIME, PATH);
return new Start(URI.create(endpoint + separator + query.entrySet().stream()
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"))), cookie);
}
/** @return the signed-in user's name */
public String callback(Map<String, String> query, String cookieHeader) throws OidcException {
Map<String, String> login = Cookies.read(cookieHeader, COOKIE).flatMap(t -> tokens.read(PURPOSE, t))
.orElseThrow(() -> new OidcException(FAILED, "no login in progress, or it took too long"));
boolean sameState = MessageDigest.isEqual(login.get("state").getBytes(StandardCharsets.UTF_8),
query.getOrDefault("state", "").getBytes(StandardCharsets.UTF_8));
if (!sameState) {
throw new OidcException(FAILED, "wrong state");
}
if ("access_denied".equals(query.get("error"))) {
throw new OidcException(DENIED, "the user denied access");
}
String code = query.get("code");
if (code == null || code.isEmpty()) {
throw new OidcException(FAILED, "the identity provider answered with " + printable(query.get("error")));
}
return verifier.verify(provider.exchange(code, login.get("verifier")), login.get("nonce"), provider);
}
public String clearCookie() {
return Cookies.clear(COOKIE, PATH);
}
/** The provider's text goes into the log: letters, digits and a few separators only, and short. */
static String printable(String text) {
String safe = text == null ? "nothing" : text.replaceAll("[^A-Za-z0-9_. -]", "?");
return safe.length() > 40 ? safe.substring(0, 40) + "..." : safe;
}
private static String random() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64Url.encode(bytes);
}
private static String challenge(String verifierText) {
try {
return Base64Url.encode(MessageDigest.getInstance("SHA-256")
.digest(verifierText.getBytes(StandardCharsets.US_ASCII)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is part of every JDK", e);
}
}
}
@@ -0,0 +1,179 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.math.BigInteger;
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.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
import static home.backend.adapters.auth.OidcException.Reason.UNAVAILABLE;
/**
* The identity provider over HTTP: discovery, signing keys and the code exchange. Everything is fetched on first
* use and kept, so an identity provider that is down never delays the start and never affects local login.
*/
final class OidcProvider implements IdTokenVerifier.Keys {
record Endpoints(URI authorization, URI token, URI jwks) {
}
private static final ObjectMapper JSON = new ObjectMapper();
private static final Duration CACHE = Duration.ofHours(1);
/** An unknown key id may mean a rotation; look again, but not on every forged token. */
private static final Duration REFETCH_AFTER = Duration.ofMinutes(1);
private final OidcSettings settings;
private final HttpClient http;
private final Duration timeout;
private final Clock clock;
private Endpoints endpoints;
private Instant endpointsAt = Instant.MIN;
private Map<String, PublicKey> keys = Map.of();
private Instant keysAt = Instant.MIN;
OidcProvider(OidcSettings settings, Duration timeout, Clock clock) {
this.settings = settings;
this.timeout = timeout;
this.clock = clock;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
}
/** The network calls run outside the lock, so a hanging identity provider holds up only its own callers. */
Endpoints endpoints() throws OidcException {
synchronized (this) {
if (endpoints != null && !endpointsAt.plus(CACHE).isBefore(clock.instant())) {
return endpoints;
}
}
String base = settings.issuerBase();
JsonNode doc = getJson(URI.create(base + "/.well-known/openid-configuration"));
if (!base.equals(doc.path("issuer").asText("").replaceAll("/+$", ""))) {
throw new OidcException(FAILED, "the discovery document names another issuer");
}
var fresh = new Endpoints(uri(doc, "authorization_endpoint"), uri(doc, "token_endpoint"), uri(doc, "jwks_uri"));
synchronized (this) {
endpoints = fresh;
endpointsAt = clock.instant();
}
return fresh;
}
@Override
public PublicKey key(String kid) throws OidcException {
synchronized (this) {
boolean stale = keysAt.plus(CACHE).isBefore(clock.instant());
boolean unknownAndRefetchable = !keys.containsKey(kid)
&& keysAt.plus(REFETCH_AFTER).isBefore(clock.instant());
if (!stale && !unknownAndRefetchable) {
return known(kid);
}
}
Map<String, PublicKey> fresh = parseKeys(getJson(endpoints().jwks()));
synchronized (this) {
keys = fresh;
keysAt = clock.instant();
return known(kid);
}
}
private PublicKey known(String kid) throws OidcException {
PublicKey key = keys.get(kid);
if (key == null) {
throw new OidcException(FAILED, "no signing key " + kid);
}
return key;
}
/** @return the ID token the identity provider issued for the code */
String exchange(String code, String verifier) throws OidcException {
var form = Map.of("grant_type", "authorization_code", "code", code,
"redirect_uri", settings.redirectUri().toString(), "code_verifier", verifier);
String credentials = encode(settings.clientId()) + ":" + encode(settings.clientSecret());
var request = HttpRequest.newBuilder(endpoints().token()).timeout(timeout)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", "Basic " + Base64.getEncoder()
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8)))
.POST(HttpRequest.BodyPublishers.ofString(form.entrySet().stream()
.map(e -> e.getKey() + "=" + encode(e.getValue())).collect(Collectors.joining("&"))))
.build();
JsonNode answer = send(request, "the token endpoint");
String idToken = answer.path("id_token").asText("");
if (idToken.isEmpty()) {
throw new OidcException(FAILED, "the token endpoint sent no id_token");
}
return idToken;
}
private JsonNode getJson(URI uri) throws OidcException {
return send(HttpRequest.newBuilder(uri).timeout(timeout).GET().build(), uri.getPath());
}
private JsonNode send(HttpRequest request, String what) throws OidcException {
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 500) {
throw new OidcException(UNAVAILABLE, what + " answered " + response.statusCode());
}
if (response.statusCode() != 200) {
throw new OidcException(FAILED, what + " answered " + response.statusCode());
}
return JSON.readTree(response.body());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new OidcException(UNAVAILABLE, what + " was interrupted", e);
} catch (IOException e) {
throw new OidcException(UNAVAILABLE, what + " is unreachable: " + e, e);
}
}
/** A key that cannot be read is skipped, so one odd entry does not lock everyone out. */
private static Map<String, PublicKey> parseKeys(JsonNode jwks) {
Map<String, PublicKey> keys = new HashMap<>();
for (JsonNode jwk : jwks.path("keys")) {
boolean signingRsa = "RSA".equals(jwk.path("kty").asText()) && !"enc".equals(jwk.path("use").asText());
if (signingRsa && jwk.hasNonNull("kid")) {
try {
keys.put(jwk.get("kid").asText(), KeyFactory.getInstance("RSA").generatePublic(
new RSAPublicKeySpec(integer(jwk, "n"), integer(jwk, "e"))));
} catch (GeneralSecurityException | IllegalArgumentException e) {
System.err.println("home-pump-backend: skipping unusable signing key " + jwk.get("kid").asText());
}
}
}
return keys;
}
private static BigInteger integer(JsonNode jwk, String field) {
return new BigInteger(1, Base64Url.decode(jwk.path(field).asText()));
}
private static URI uri(JsonNode doc, String field) throws OidcException {
String value = doc.path(field).asText("");
if (value.isEmpty()) {
throw new OidcException(FAILED, "the discovery document has no " + field);
}
return URI.create(value);
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,12 @@
package home.backend.adapters.auth;
import java.net.URI;
/** requiredRole is null when any user of the realm may sign in. */
public record OidcSettings(URI issuer, String clientId, String clientSecret, URI redirectUri, String requiredRole) {
/** The issuer without a trailing slash, the form tokens and discovery are compared in. */
String issuerBase() {
return issuer.toString().replaceAll("/+$", "");
}
}
@@ -0,0 +1,78 @@
package home.backend.adapters.auth;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/** Password hashes as {@code pbkdf2-sha256$<iterations>$<salt b64>$<hash b64>}: the cost travels with the hash. */
public final class PasswordHash {
private static final String SCHEME = "pbkdf2-sha256";
private static final int ITERATIONS = 600_000;
/** A file with a cheaper hash is refused, so a weak one cannot slip in. */
private static final int MIN_ITERATIONS = 310_000;
private static final int SALT_BYTES = 16;
private static final int HASH_BITS = 256;
private static final SecureRandom RANDOM = new SecureRandom();
private record Parsed(int iterations, byte[] salt, byte[] hash) {
}
private PasswordHash() {
}
public static String create(char[] password) {
byte[] salt = new byte[SALT_BYTES];
RANDOM.nextBytes(salt);
return String.join("$", SCHEME, String.valueOf(ITERATIONS), Base64.getEncoder().encodeToString(salt),
Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS, HASH_BITS)));
}
/** Compares in constant time. */
public static boolean matches(String stored, char[] password) {
Parsed parsed = parse(stored);
byte[] actual = derive(password, parsed.salt(), parsed.iterations(), parsed.hash().length * 8);
return MessageDigest.isEqual(parsed.hash(), actual);
}
/** @throws IllegalArgumentException when the text is not a hash this class made or accepts */
public static void requireValid(String stored) {
parse(stored);
}
private static Parsed parse(String stored) {
String[] parts = stored == null ? new String[0] : stored.split("\\$");
if (parts.length != 4 || !SCHEME.equals(parts[0])) {
throw new IllegalArgumentException("a password hash must look like " + SCHEME
+ "$<iterations>$<salt>$<hash>; create one with: hash-password");
}
try {
int iterations = Integer.parseInt(parts[1]);
byte[] salt = Base64.getDecoder().decode(parts[2]);
byte[] hash = Base64.getDecoder().decode(parts[3]);
if (iterations < MIN_ITERATIONS || salt.length < SALT_BYTES || hash.length < HASH_BITS / 8) {
throw new IllegalArgumentException("password hash is too weak: at least " + MIN_ITERATIONS
+ " iterations, 16 bytes of salt, a 32 byte hash");
}
return new Parsed(iterations, salt, hash);
} catch (IllegalArgumentException e) {
// NumberFormatException and a bad base64 are both IllegalArgumentException
throw new IllegalArgumentException("password hash is malformed or too weak (at least " + MIN_ITERATIONS
+ " iterations, 16 bytes of salt, a 32 byte hash)", e);
}
}
private static byte[] derive(char[] password, byte[] salt, int iterations, int bits) {
var spec = new PBEKeySpec(password, salt, iterations, bits);
try {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("PBKDF2WithHmacSHA256 is part of every JDK", e);
} finally {
spec.clearPassword();
}
}
}
@@ -0,0 +1,40 @@
package home.backend.adapters.auth;
import java.io.BufferedReader;
import java.io.Console;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/** {@code hash-password}: prints the hash for USERS_FILE; on a terminal the password is asked twice, unechoed. */
public final class PasswordHashCli {
private PasswordHashCli() {
}
/** @return the exit code */
public static int run(Console console, InputStream stdin, PrintStream out, PrintStream err) throws IOException {
char[] password;
if (console != null) {
password = console.readPassword("Password: ");
char[] again = console.readPassword("Again: ");
if (password == null || !Arrays.equals(password, again)) {
err.println("The passwords differ.");
return 1;
}
} else {
String line = new BufferedReader(new InputStreamReader(stdin, StandardCharsets.UTF_8)).readLine();
password = line == null ? null : line.toCharArray();
}
if (password == null || password.length == 0) {
err.println("No password given.");
return 1;
}
out.println(PasswordHash.create(password));
Arrays.fill(password, '\0');
return 0;
}
}
@@ -0,0 +1,19 @@
package home.backend.adapters.auth;
import java.time.Instant;
import java.util.regex.Pattern;
/** Who is signed in and how. {@code method} is {@code local}, {@code oidc} or {@code none} (AUTH_DISABLED). */
public record Session(String user, String method, Instant issuedAt) {
/** The name goes to the gateway's audit log as a header, so only plain characters are allowed. */
public static final String USER_PATTERN = "[\\w.@+-]{1,64}";
private static final Pattern USER = Pattern.compile(USER_PATTERN);
/** Every request is this one when authentication is switched off for development. */
static final Session OPEN = new Session("phone", "none", Instant.EPOCH);
public static boolean isValidUser(String name) {
return USER.matcher(name).matches();
}
}
@@ -0,0 +1,40 @@
package home.backend.adapters.auth;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
/** The session cookie: a signed token that holds the user, the method and when it was issued. */
final class Sessions {
static final String COOKIE = "hp_session";
private static final String PURPOSE = "session";
private final SignedTokens tokens;
private final Clock clock;
private final Duration lifetime;
Sessions(SignedTokens tokens, Clock clock, Duration lifetime) {
this.tokens = tokens;
this.clock = clock;
this.lifetime = lifetime;
}
String setCookie(String user, String method) {
String token = tokens.issue(PURPOSE, lifetime, Map.of("user", user, "method", method,
"iat", String.valueOf(clock.instant().getEpochSecond())));
return Cookies.set(COOKIE, token, lifetime, "/");
}
String clearCookie() {
return Cookies.clear(COOKIE, "/");
}
Optional<Session> read(String cookieHeader) {
return Cookies.read(cookieHeader, COOKIE).flatMap(token -> tokens.read(PURPOSE, token)).map(claims ->
new Session(claims.get("user"), claims.get("method"),
Instant.ofEpochSecond(Long.parseLong(claims.get("iat")))));
}
}
@@ -0,0 +1,71 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/** Tamper-proof, expiring text for cookies: {@code base64url(json).base64url(HMAC-SHA256)}. */
final class SignedTokens {
private static final ObjectMapper JSON = new ObjectMapper();
private static final TypeReference<Map<String, String>> CLAIMS = new TypeReference<>() {
};
private static final String HMAC = "HmacSHA256";
private final SecretKeySpec key;
private final Clock clock;
SignedTokens(byte[] secret, Clock clock) {
this.key = new SecretKeySpec(secret, HMAC);
this.clock = clock;
}
/** The purpose keeps a token made for one use (a session) from passing as another (a login in progress). */
String issue(String purpose, Duration lifetime, Map<String, String> claims) {
var all = new HashMap<>(claims);
all.put("purpose", purpose);
all.put("exp", String.valueOf(clock.instant().plus(lifetime).getEpochSecond()));
try {
String payload = Base64Url.encode(JSON.writeValueAsBytes(all));
return payload + "." + Base64Url.encode(sign(payload));
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
Optional<Map<String, String>> read(String purpose, String token) {
try {
String[] parts = token.split("\\.", -1);
if (parts.length != 2 || !MessageDigest.isEqual(sign(parts[0]), Base64Url.decode(parts[1]))) {
return Optional.empty();
}
Map<String, String> claims = JSON.readValue(Base64Url.decode(parts[0]), CLAIMS);
boolean valid = purpose.equals(claims.get("purpose"))
&& Long.parseLong(claims.get("exp")) > clock.instant().getEpochSecond();
return valid ? Optional.of(claims) : Optional.empty();
} catch (IOException | IllegalArgumentException e) {
return Optional.empty();
}
}
private byte[] sign(String payload) {
try {
Mac mac = Mac.getInstance(HMAC);
mac.init(key);
return mac.doFinal(payload.getBytes(StandardCharsets.US_ASCII));
} catch (GeneralSecurityException e) {
throw new IllegalStateException(HMAC + " is part of every JDK", e);
}
}
}
@@ -0,0 +1,151 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* The local accounts of USERS_FILE: {@code [{"username", "passwordHash"}]}. A missing file is an empty store
* (setup mode). Every change is written back atomically before it counts, under one lock.
*/
public final class UserStore {
public enum Removal { REMOVED, UNKNOWN, LAST }
private record Entry(String username, String passwordHash) {
}
private static final ObjectMapper JSON = new ObjectMapper();
/** What an unknown user is checked against, so that it costs as much as a wrong password. */
private static final String DECOY = PasswordHash.create(randomPassword());
private final Path file;
private Map<String, String> hashes;
UserStore(Path file, Map<String, String> hashes) {
this.file = file;
this.hashes = new LinkedHashMap<>(hashes);
}
/** @throws IllegalArgumentException when the file exists but is unreadable or holds a bad account */
public static UserStore load(Path file) {
Map<String, String> hashes = new LinkedHashMap<>();
if (Files.exists(file)) {
List<Entry> entries;
try {
entries = JSON.readValue(file.toFile(), new TypeReference<>() {
});
} catch (IOException e) {
throw new IllegalArgumentException("USERS_FILE " + file + " is not usable: " + e.getMessage(), e);
}
for (Entry entry : entries) {
if (entry.username() == null || !Session.isValidUser(entry.username())) {
throw new IllegalArgumentException("USERS_FILE: a username must match " + Session.USER_PATTERN);
}
PasswordHash.requireValid(entry.passwordHash());
if (hashes.put(entry.username(), entry.passwordHash()) != null) {
throw new IllegalArgumentException("USERS_FILE: duplicate username " + entry.username());
}
}
}
return new UserStore(file, hashes);
}
public synchronized boolean isEmpty() {
return hashes.isEmpty();
}
public synchronized boolean exists(String username) {
return hashes.containsKey(username);
}
public synchronized List<String> usernames() {
return List.copyOf(hashes.keySet());
}
public boolean authenticate(String username, char[] password) {
String stored;
synchronized (this) {
stored = hashes.get(username);
}
boolean matches = PasswordHash.matches(stored == null ? DECOY : stored, password);
return stored != null && matches;
}
/** The first account; false when there already is one, so only one setup can win. */
public synchronized boolean addFirst(String username, String hash) {
return hashes.isEmpty() && put(username, hash);
}
/** @return false when the name is taken */
public synchronized boolean add(String username, String hash) {
return !hashes.containsKey(username) && put(username, hash);
}
/** @return false when there is no such account */
public synchronized boolean changePassword(String username, String hash) {
return hashes.containsKey(username) && put(username, hash);
}
public synchronized Removal remove(String username) {
if (!hashes.containsKey(username)) {
return Removal.UNKNOWN;
}
if (hashes.size() == 1) {
return Removal.LAST;
}
var next = new LinkedHashMap<>(hashes);
next.remove(username);
write(next);
hashes = next;
return Removal.REMOVED;
}
private boolean put(String username, String hash) {
var next = new LinkedHashMap<>(hashes);
next.put(username, hash);
write(next);
hashes = next;
return true;
}
/** Temp file next to the target, synced, then renamed over it; the old file stays if anything fails. */
private void write(Map<String, String> accounts) {
Path directory = file.toAbsolutePath().getParent();
Path temp = null;
try {
temp = Files.createTempFile(directory, "users", ".tmp",
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
Files.write(temp, JSON.writerWithDefaultPrettyPrinter().writeValueAsBytes(accounts.entrySet().stream()
.map(e -> new Entry(e.getKey(), e.getValue())).toList()));
try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
channel.force(true);
}
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
if (temp != null) {
temp.toFile().delete();
}
throw new UncheckedIOException("cannot write USERS_FILE " + file, e);
}
}
private static char[] randomPassword() {
byte[] bytes = new byte[24];
new SecureRandom().nextBytes(bytes);
return Base64.getEncoder().encodeToString(bytes).toCharArray();
}
}
@@ -9,6 +9,7 @@ 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.adapters.auth.Authentication;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
@@ -17,6 +18,7 @@ import home.backend.application.GatewayRefresh;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.ProgramService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
@@ -55,8 +57,11 @@ public class ApiServer {
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) {
/**
* {@code hours}: how long a timed preset lasts; {@code values}: the user's own value per setpoint of the
* preset, by setpoint name.
*/
private record PresetRequest(String until, Integer hours, Map<String, Integer> values) {
PresetRequest {
if (values == null) {
@@ -73,11 +78,6 @@ public class ApiServer {
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";
private static final Pattern USER_NAME = Pattern.compile("[\\w.@+-]{1,64}");
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
@@ -85,22 +85,40 @@ public class ApiServer {
private static final int STOP_GRACE_SECONDS = 5;
private final HttpServer server;
private final SessionFilter sessionFilter;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
/** Binds the port now; 0 picks a free one. */
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather,
GatewayRefresh refresh) throws IOException {
BatteryService battery, AuditService audit, StatsService stats, ProgramService program, WeatherService weather,
GatewayRefresh refresh, Authentication auth) throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(executor);
sessionFilter = new SessionFilter(auth);
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
context("/", exchange -> {
open("/", exchange -> {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
});
var signIn = new AuthEndpoints(auth);
openRoute("/api/auth/config", signIn::config);
openRoute("/api/auth/login", signIn::login);
openRoute("/api/auth/logout", signIn::logout);
openRoute("/api/auth/me", signIn::me);
openRoute("/api/auth/setup", signIn::setup);
openRoute("/api/auth/users", signIn::users);
openRoute("/api/auth/password", signIn::password);
open("/api/auth/users/", signIn::deleteUser);
openRoute("/api/auth/oidc/start", signIn::oidcStart);
openRoute("/api/auth/oidc/callback", signIn::oidcCallback);
// The container healthcheck: no session, nothing in the answer.
openRoute("/api/live", exchange -> handle(exchange, Map.of("GET", () -> {
exchange.sendResponseHeaders(200, 2);
exchange.getResponseBody().write("ok".getBytes(StandardCharsets.US_ASCII));
})));
api("/api/heat-pump", query -> heatPump.current());
api("/api/energy", query -> energy.current());
api("/api/energy/details", query -> energyDetails.current());
@@ -117,6 +135,7 @@ public class ApiServer {
route("/api/history", exchange -> handleHistory(exchange, history));
route("/api/audit", exchange -> handleAudit(exchange, audit));
route("/api/stats", exchange -> handleStats(exchange, stats));
route("/api/program", exchange -> handleProgram(exchange, program));
route("/api/setpoints", exchange -> handle(exchange, Map.of(
"GET", () -> getSetpoints(exchange, setpoints),
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
@@ -135,9 +154,17 @@ public class ApiServer {
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
}
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
private void route(String path, HttpHandler handler) {
context(path, exchange -> {
context(path, exactly(path, handler));
}
private void openRoute(String path, HttpHandler handler) {
open(path, exactly(path, handler));
}
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
private static HttpHandler exactly(String path, HttpHandler handler) {
return exchange -> {
if (path.equals(exchange.getRequestURI().getPath())) {
handler.handle(exchange);
} else {
@@ -145,10 +172,18 @@ public class ApiServer {
exchange.sendResponseHeaders(404, -1);
}
}
});
};
}
/** Needs a session. */
private void context(String path, HttpHandler handler) {
var context = server.createContext(path, handler);
context.getFilters().add(SECURITY_HEADERS);
context.getFilters().add(sessionFilter);
}
/** Answers without a session. */
private void open(String path, HttpHandler handler) {
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
}
@@ -184,7 +219,7 @@ public class ApiServer {
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
*/
private static void guarded(HttpExchange exchange, Write write) throws IOException {
static void guarded(HttpExchange exchange, Write write) throws IOException {
var headers = exchange.getRequestHeaders();
String origin = headers.getFirst("Origin");
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
@@ -284,14 +319,17 @@ public class ApiServer {
throws IOException {
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
try {
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
var request = body.length == 0 ? new PresetRequest(null, 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));
var result = setpoints.applyPreset(id, until, request.hours(), request.values(),
user(exchange));
if (result.isPresent()) {
send(exchange, presetHttpStatus(result.get().status()), result.get());
} else {
send(exchange, 404, error("no such preset"));
}
} catch (SetpointService.InvalidHoursException e) {
send(exchange, 400, error("invalid_hours"));
} 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 "
@@ -337,9 +375,9 @@ public class ApiServer {
};
}
/** The signed-in user, set by the session filter. */
private static String user(HttpExchange exchange) {
String name = exchange.getRequestHeaders().getFirst(USER_HEADER);
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
return (String) exchange.getAttribute(SessionFilter.USER_ATTRIBUTE);
}
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
@@ -380,15 +418,27 @@ public class ApiServer {
}));
}
private interface Action {
private void handleProgram(HttpExchange exchange, ProgramService program) throws IOException {
handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, program.program(parseQuery(exchange.getRequestURI().getRawQuery()).get("days")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("program unavailable"));
}
}));
}
interface Action {
void run() throws IOException;
}
private interface Write {
interface Write {
void run(byte[] body) throws IOException;
}
private static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
try (exchange) {
Action action = byMethod.get(exchange.getRequestMethod());
if (action == null) {
@@ -407,7 +457,7 @@ public class ApiServer {
}
}
private static void send(HttpExchange exchange, int status, Object body) throws IOException {
static void send(HttpExchange exchange, int status, Object body) throws IOException {
byte[] bytes = JSON.writeValueAsBytes(body);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
@@ -419,7 +469,7 @@ public class ApiServer {
return Map.of("error", message);
}
private static Map<String, String> parseQuery(String raw) {
static Map<String, String> parseQuery(String raw) {
Map<String, String> query = new HashMap<>();
if (raw == null) {
return query;
@@ -0,0 +1,284 @@
package home.backend.adapters.http;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.auth.OidcException;
import home.backend.adapters.auth.Session;
import java.io.IOException;
import java.util.Map;
import java.util.regex.Pattern;
/** The endpoints under {@code /api/auth/}: sign-in needs no session, managing accounts does. */
final class AuthEndpoints {
private record LoginRequest(String username, String password) {
}
private record PasswordRequest(String current, @JsonProperty("new") String next) {
}
private static final Pattern DELETE_PATH = Pattern.compile("/api/auth/users/([^/]{1,64})/delete");
private static final ObjectMapper JSON = new ObjectMapper();
private static final int MAX_CREDENTIAL_LENGTH = 256;
private final Authentication auth;
AuthEndpoints(Authentication auth) {
this.auth = auth;
}
void config(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> ApiServer.send(exchange, 200,
Map.of("local", auth.localEnabled(), "oidc", auth.oidcEnabled(),
"setup", auth.setupNeeded()))));
}
void login(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> postLogin(exchange, body))));
}
void logout(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
String cookie = auth.clearedSessionCookie();
if (!cookie.isEmpty()) {
exchange.getResponseHeaders().add("Set-Cookie", cookie);
}
exchange.sendResponseHeaders(204, -1);
})));
}
void me(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> {
var session = auth.session(SessionFilter.cookies(exchange));
if (session.isPresent()) {
ApiServer.send(exchange, 200, user(session.get()));
} else {
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
}
}));
}
void oidcStart(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> {
if (!auth.oidcEnabled()) {
exchange.sendResponseHeaders(404, -1);
return;
}
try {
var start = auth.oidc().start();
exchange.getResponseHeaders().add("Set-Cookie", start.setCookie());
redirect(exchange, start.location().toString());
} catch (OidcException e) {
redirectWithError(exchange, e);
}
}));
}
void oidcCallback(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> {
if (!auth.oidcEnabled()) {
exchange.sendResponseHeaders(404, -1);
return;
}
exchange.getResponseHeaders().add("Set-Cookie", auth.oidc().clearCookie());
try {
String name = auth.oidc().callback(ApiServer.parseQuery(exchange.getRequestURI().getRawQuery()),
SessionFilter.cookies(exchange));
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(name, "oidc"));
redirect(exchange, "/");
} catch (OidcException e) {
redirectWithError(exchange, e);
}
}));
}
private void postLogin(HttpExchange exchange, byte[] body) throws IOException {
if (!auth.localEnabled()) {
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
return;
}
LoginRequest request = credentials(exchange, body);
if (request == null) {
return;
}
String ip = ClientIp.of(exchange, auth.proxyHops());
var login = auth.login(request.username(), request.password().toCharArray(), ip);
switch (login.status()) {
case OK -> {
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
}
case INVALID -> {
System.err.println("home-pump-backend: failed login from " + ip);
ApiServer.send(exchange, 401, Map.of("error", "invalid"));
}
case THROTTLED -> throttled(exchange, login);
}
}
/** The username and password of the body, or null after answering 400. */
private static LoginRequest credentials(HttpExchange exchange, byte[] body) throws IOException {
LoginRequest request;
try {
request = JSON.readValue(body, LoginRequest.class);
} catch (JsonProcessingException e) {
request = null;
}
if (request == null || request.username() == null || request.password() == null
|| request.username().length() > MAX_CREDENTIAL_LENGTH
|| request.password().length() > MAX_CREDENTIAL_LENGTH) {
ApiServer.send(exchange, 400, Map.of("error", "expected {\"username\", \"password\"}"));
return null;
}
return request;
}
private static void throttled(HttpExchange exchange, Authentication.Login login) throws IOException {
exchange.getResponseHeaders().set("Retry-After", String.valueOf(login.retryAfterSeconds()));
ApiServer.send(exchange, 429, Map.of("error", "throttled", "retryAfterSeconds", login.retryAfterSeconds()));
}
/** True when the name and password may become an account; else answers 400. */
private static boolean acceptable(HttpExchange exchange, String username, String password) throws IOException {
if (!Session.isValidUser(username)) {
ApiServer.send(exchange, 400, Map.of("error", "invalid_username"));
return false;
}
if (!Authentication.strongEnough(password)) {
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
return false;
}
return true;
}
void setup(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
if (!auth.localEnabled()) {
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
return;
}
LoginRequest request = credentials(exchange, body);
if (request == null) {
return;
}
if (!auth.setupNeeded()) {
ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
return;
}
if (!acceptable(exchange, request.username(), request.password())) {
return;
}
var result = auth.setup(request.username(), request.password().toCharArray(),
ClientIp.of(exchange, auth.proxyHops()));
switch (result.status()) {
case OK -> {
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
}
case INVALID -> ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
case THROTTLED -> throttled(exchange, result);
}
})));
}
/** Runs the action for a signed-in user of a configured local store; else answers 401 or 404. */
private void asUser(HttpExchange exchange, SignedIn action) throws IOException {
var session = auth.session(SessionFilter.cookies(exchange));
if (session.isEmpty()) {
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
} else if (!auth.localEnabled()) {
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
} else {
action.run(session.get());
}
}
private interface SignedIn {
void run(Session session) throws IOException;
}
void users(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of(
"GET", () -> asUser(exchange, session -> ApiServer.send(exchange, 200,
auth.usernames().stream().map(name -> Map.of("username", name)).toList())),
"POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
LoginRequest request = credentials(exchange, body);
if (request == null || !acceptable(exchange, request.username(), request.password())) {
return;
}
if (auth.addUser(session.user(), request.username(), request.password().toCharArray())) {
ApiServer.send(exchange, 200, Map.of("username", request.username()));
} else {
ApiServer.send(exchange, 409, Map.of("error", "exists"));
}
}))));
}
void password(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
PasswordRequest request;
try {
request = JSON.readValue(body, PasswordRequest.class);
} catch (JsonProcessingException e) {
request = null;
}
if (request == null || request.current() == null || request.next() == null
|| request.current().length() > MAX_CREDENTIAL_LENGTH
|| request.next().length() > MAX_CREDENTIAL_LENGTH) {
ApiServer.send(exchange, 400, Map.of("error", "expected {\"current\", \"new\"}"));
} else if (!Authentication.LOCAL.equals(session.method())) {
ApiServer.send(exchange, 403, Map.of("error", "local_only"));
} else if (!Authentication.strongEnough(request.next())) {
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
} else {
var result = auth.changePassword(session.user(), request.current().toCharArray(),
request.next().toCharArray(), ClientIp.of(exchange, auth.proxyHops()));
switch (result.status()) {
case OK -> exchange.sendResponseHeaders(204, -1);
case INVALID -> ApiServer.send(exchange, 401, Map.of("error", "invalid"));
case THROTTLED -> throttled(exchange, result);
}
}
}))));
}
void deleteUser(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
var path = DELETE_PATH.matcher(exchange.getRequestURI().getRawPath());
if (!path.matches()) {
exchange.sendResponseHeaders(404, -1);
return;
}
String name = path.group(1);
switch (auth.removeUser(session.user(), name)) {
case UNKNOWN -> ApiServer.send(exchange, 404, Map.of("error", "unknown_user"));
case LAST -> ApiServer.send(exchange, 409, Map.of("error", "last_user"));
case REMOVED -> {
if (name.equals(session.user())) {
exchange.getResponseHeaders().add("Set-Cookie", auth.clearedSessionCookie());
}
exchange.sendResponseHeaders(204, -1);
}
}
}))));
}
private static Map<String, String> user(Session session) {
return Map.of("user", session.user(), "method", session.method());
}
private static void redirectWithError(HttpExchange exchange, OidcException e) throws IOException {
System.err.println("home-pump-backend: OIDC sign-in failed: " + e.getMessage());
redirect(exchange, "/#login?error=" + e.reason().code());
}
private static void redirect(HttpExchange exchange, String location) throws IOException {
exchange.getResponseHeaders().set("Location", location);
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(302, -1);
}
}
@@ -0,0 +1,29 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.HttpExchange;
import java.util.Arrays;
import java.util.List;
/**
* The client's address for the login throttle. Each trusted proxy appends the address it saw to
* X-Forwarded-For, so the entry {@code hops} from the right is the first one no trusted proxy vouches for
* beyond that; anything the client put in front of it is ignored. Without enough entries the request did not
* come through the proxies, and the socket's address is used.
*/
final class ClientIp {
private ClientIp() {
}
static String of(HttpExchange exchange, int hops) {
List<String> values = exchange.getRequestHeaders().get("X-Forwarded-For");
if (values != null) {
List<String> chain = Arrays.stream(String.join(",", values).split(",")).map(String::trim).toList();
if (chain.size() >= hops && !chain.get(chain.size() - hops).isEmpty()) {
return chain.get(chain.size() - hops);
}
}
return exchange.getRemoteAddress().getAddress().getHostAddress();
}
}
@@ -0,0 +1,45 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.Filter;
import com.sun.net.httpserver.HttpExchange;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.auth.Session;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
/** Lets a request through only with a valid session, and hands the user's name on to the handlers. */
final class SessionFilter extends Filter {
static final String USER_ATTRIBUTE = "user";
private final Authentication auth;
SessionFilter(Authentication auth) {
this.auth = auth;
}
@Override
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
Optional<Session> session = auth.session(cookies(exchange));
if (session.isPresent()) {
exchange.setAttribute(USER_ATTRIBUTE, session.get().user());
chain.doFilter(exchange);
} else {
try (exchange) {
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
}
}
}
static String cookies(HttpExchange exchange) {
var values = exchange.getRequestHeaders().get("Cookie");
return values == null ? null : String.join("; ", values);
}
@Override
public String description() {
return "session check";
}
}
@@ -4,6 +4,9 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.Periods;
import java.io.IOException;
import java.time.Instant;
@@ -19,7 +22,9 @@ final class InfluxSamples {
}
static String query(long fromMs, long toMs) {
return "SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
return "SELECT output_compressor, output_cwu, output_co, outdoor_temp, cwu_temp,"
+ " program, target_room_temp, target_cwu_temp, setpoint_co_economy, setpoint_co_comfort,"
+ " setpoint_cwu_economy, setpoint_cwu_comfort"
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
}
@@ -38,14 +43,25 @@ final class InfluxSamples {
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)));
number(row, 1), number(row, 2), number(row, 3),
number(row, 4), number(row, 5), periods(row)));
}
}
}
return samples;
}
private static Periods periods(JsonNode row) {
var targets = new Targets(number(row, 7), null, number(row, 8));
var setpoints = new Setpoints(whole(row, 9), whole(row, 10), whole(row, 11), whole(row, 12));
return Periods.of(whole(row, 6), targets, setpoints);
}
private static Integer whole(JsonNode row, int column) {
Double value = number(row, column);
return value == null ? null : (int) Math.round(value);
}
private static Double number(JsonNode row, int column) {
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
}
@@ -13,7 +13,6 @@ final class DayTally {
private double compressorSeconds;
private double cwuSeconds;
private double coSeconds;
private double heaterSeconds;
private int starts;
private int samples;
private double outdoorSum;
@@ -39,7 +38,6 @@ final class DayTally {
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() {
@@ -50,7 +48,6 @@ final class DayTally {
compressorSeconds += other.compressorSeconds;
cwuSeconds += other.cwuSeconds;
coSeconds += other.coSeconds;
heaterSeconds += other.heaterSeconds;
starts += other.starts;
samples += other.samples;
outdoorSum += other.outdoorSum;
@@ -63,15 +60,15 @@ final class DayTally {
Day day(LocalDate date) {
if (samples == 0) {
return new Day(date, null, null, null, null, null, null, null, null);
return new Day(date, null, null, null, null, null, null, null);
}
return new Day(date, hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
avgOutdoor(), minCwu, maxCwu);
}
Totals totals() {
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
avgOutdoor(), minCwu, maxCwu);
}
static boolean on(Double output) {
@@ -69,7 +69,7 @@ public class EnergyDetailsService {
phases("grid_power_l%d_w", "Moc", "W", 0),
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
group("Odbiór domu",
group("Wyjście zapasowe falownika",
field("load_power_w", "Moc razem", "W", 0),
phases("load_power_l%d_w", "Moc", "W", 0)),
group("Panele PV",
@@ -81,14 +81,14 @@ public class EnergyDetailsService {
field("pv2_current_a", "PV2 prąd", "A", 1)),
group("Dziś",
field("pv_today_kwh", "Produkcja PV", "kWh", 1),
field("load_today_kwh", "Zużycie domu", "kWh", 1),
field("load_today_kwh", "Wyjście zapasowe", "kWh", 1),
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
group("Od początku",
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
field("load_total_kwh", "Zużycie domu", "kWh", 0),
field("load_total_kwh", "Wyjście zapasowe", "kWh", 0),
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
@@ -11,9 +11,20 @@ import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
/**
* F7: house electricity from the Deye inverter, the Fronius PV inverter and the house meter.
* The Deye "load" registers cover only its backup output, so the house is a balance:
* <ul>
* <li>now = Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, where grid is the
* house meter's total active power when it is live, else the Deye's grid power; floored at 0, null when
* any of those readings is missing or stale;</li>
* <li>today = Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged
* (Deye day counters), null when any is missing.</li>
* </ul>
*/
public class EnergyService {
private final GatewayClient gateway;
@@ -30,17 +41,42 @@ public class EnergyService {
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
Map<String, Double> d = deye.fields();
Map<String, Double> f = fronius.fields();
Map<String, Double> meter = SnapshotFields.live(snapshot, "house-meter", "PRAD2", clock.instant()).fields();
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), d.get("load_today_kwh"));
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), houseKwh(d, froniusKwh));
List<Source> sources = List.of(deye.source(), fronius.source());
return new EnergyView(oldest(sources), sources,
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
housePowerW(d, f, meter.getOrDefault("total_power_active", d.get("grid_power_w"))),
d.get("load_power_w"), today);
}
private static Double housePowerW(Map<String, Double> deye, Map<String, Double> fronius, Double gridW) {
Double pv1 = deye.get("pv1_power_w");
Double pv2 = deye.get("pv2_power_w");
Double pvFronius = fronius.get("pv_power_w");
Double battery = deye.get("battery_power_w");
if (Stream.of(pv1, pv2, pvFronius, battery, gridW).anyMatch(Objects::isNull)) {
return null;
}
return Math.max(0, pv1 + pv2 + pvFronius + battery + gridW);
}
private static Double houseKwh(Map<String, Double> deye, Double froniusKwh) {
Double pv = deye.get("pv_today_kwh");
Double bought = deye.get("grid_bought_today_kwh");
Double sold = deye.get("grid_sold_today_kwh");
Double discharged = deye.get("battery_discharge_today_kwh");
Double charged = deye.get("battery_charge_today_kwh");
if (Stream.of(pv, froniusKwh, bought, sold, discharged, charged).anyMatch(Objects::isNull)) {
return null;
}
return round(Math.max(0, pv + froniusKwh + bought - sold + discharged - charged));
}
private static Instant oldest(List<Source> sources) {
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
}
@@ -8,6 +8,7 @@ import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.State;
import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.HeatPumpView.Temperatures;
import home.backend.domain.Periods;
import java.io.IOException;
import java.time.Clock;
@@ -43,7 +44,11 @@ public class HeatPumpService {
private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) {
Integer alarmCode = whole(f, "alarm_code");
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
var targets = new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
tenths(f, "target_cwu_temp"));
var setpoints = new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort"));
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"),
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
on(f, "output_ground"), on(f, "output_cwu")),
@@ -51,10 +56,7 @@ public class HeatPumpService {
tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
tenths(f, "outdoor_avg_temp")),
new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
tenths(f, "target_cwu_temp")),
new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort")));
targets, setpoints, Periods.of(whole(f, "program"), targets, setpoints));
}
/** Codes of register 4011 as the vendor app names them; unverified on our device. */
@@ -0,0 +1,193 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.Period;
import home.backend.domain.Periods;
import home.backend.domain.ProgramView;
import home.backend.domain.ProgramView.Circuit;
import home.backend.domain.ProgramView.Confidence;
import home.backend.domain.ProgramView.Interval;
import home.backend.domain.ProgramView.Weekday;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.function.Function;
/**
* F6: the weekly program as the history shows it, without asking the controller (its program
* registers sit behind a shared selector, so reading them would mean writing). A transition between
* two adjacent polls, economy to comfort or back, puts a comfort boundary halfway between them; a
* poll in another period (override, or setpoints that make the periods ambiguous, for instance while
* we write one) breaks the chain, as does a gap in the data. Times are local, so a day with a change
* of time needs no special case. Today is left out: it is not over.
*/
public class ProgramService {
private static final int DEFAULT_DAYS = 14;
private static final int MIN_DAYS = 7;
private static final int MAX_DAYS = 30;
private static final Duration MAX_GAP = StatsService.MAX_HOLD;
private static final int STEP_MINUTES = 5;
private static final int MINUTES_PER_DAY = 24 * 60;
private record Span(int from, int to) {
}
/** What the history says about one circuit: days with a known period and their comfort spans. */
private record Observed(Set<LocalDate> days, Map<LocalDate, List<Span>> comfort) {
}
private final HeatPumpSamples repository;
private final Clock clock;
public ProgramService(HeatPumpSamples repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
/**
* @param days how many past days to read, 7 to 30; null means 14
* @throws IllegalArgumentException for anything else
*/
public ProgramView program(String days) throws IOException {
int length = length(days);
LocalDate today = clock.instant().atZone(StatsService.ZONE).toLocalDate();
List<HeatPumpSample> samples = repository.samples(
today.minusDays(length).atStartOfDay(StatsService.ZONE).toInstant(),
today.atStartOfDay(StatsService.ZONE).toInstant());
Observed cwu = observe(samples, Periods::cwu);
Observed room = observe(samples, Periods::room);
List<Weekday> weekdays = new ArrayList<>();
for (int day = 1; day <= 7; day++) {
weekdays.add(new Weekday(day, circuit(cwu, day), circuit(room, day)));
}
return new ProgramView(length, weekdays);
}
private static int length(String days) {
if (days == null) {
return DEFAULT_DAYS;
}
try {
int length = Integer.parseInt(days);
if (length >= MIN_DAYS && length <= MAX_DAYS) {
return length;
}
} catch (NumberFormatException e) {
// reported below
}
throw new IllegalArgumentException("days must be " + MIN_DAYS + " to " + MAX_DAYS);
}
private static Observed observe(List<HeatPumpSample> samples, Function<Periods, Period> circuit) {
Set<LocalDate> days = new TreeSet<>();
Map<LocalDate, List<Span>> comfort = new TreeMap<>();
Period previous = null;
Instant previousTime = null;
Instant comfortStart = null;
for (HeatPumpSample sample : samples) {
Period period = circuit.apply(sample.periods());
if (period != Period.COMFORT && period != Period.ECONOMY) {
previous = null;
comfortStart = null;
continue;
}
days.add(sample.t().atZone(StatsService.ZONE).toLocalDate());
boolean adjacent = previous != null && Duration.between(previousTime, sample.t()).compareTo(MAX_GAP) <= 0;
if (!adjacent) {
comfortStart = null;
} else if (period != previous) {
Instant boundary = previousTime.plus(Duration.between(previousTime, sample.t()).dividedBy(2));
if (period == Period.COMFORT) {
comfortStart = boundary;
} else if (comfortStart != null) {
addSpans(comfort, comfortStart, boundary);
comfortStart = null;
}
}
previous = period;
previousTime = sample.t();
}
return new Observed(days, comfort);
}
/** Splits at local midnight; a start at 23:58 and an end at 00:03 are rounded before that. */
private static void addSpans(Map<LocalDate, List<Span>> comfort, Instant from, Instant to) {
LocalDateTime start = rounded(from.atZone(StatsService.ZONE).toLocalDateTime());
LocalDateTime end = rounded(to.atZone(StatsService.ZONE).toLocalDateTime());
for (LocalDate date = start.toLocalDate(); !date.isAfter(end.toLocalDate()); date = date.plusDays(1)) {
int spanStart = date.equals(start.toLocalDate()) ? minutes(start) : 0;
int spanEnd = date.equals(end.toLocalDate()) ? minutes(end) : MINUTES_PER_DAY;
if (spanEnd > spanStart) {
comfort.computeIfAbsent(date, d -> new ArrayList<>()).add(new Span(spanStart, spanEnd));
}
}
}
private static LocalDateTime rounded(LocalDateTime time) {
long steps = Math.round((time.getMinute() * 60 + time.getSecond()) / (STEP_MINUTES * 60.0));
return time.truncatedTo(ChronoUnit.HOURS).plusMinutes(steps * STEP_MINUTES);
}
private static int minutes(LocalDateTime time) {
return time.getHour() * 60 + time.getMinute();
}
/**
* The pattern most days of this weekday agree on (the latest wins a tie); {@code CONFIRMED} when at
* least two days show it.
*/
private static Circuit circuit(Observed observed, int weekday) {
List<List<Span>> patterns = observed.days().stream()
.filter(date -> date.getDayOfWeek().getValue() == weekday)
.map(date -> observed.comfort().getOrDefault(date, List.of()))
.toList();
if (patterns.isEmpty()) {
return new Circuit(Confidence.NO_DATA, 0, 0, List.of());
}
List<Span> best = null;
int bestCount = 0;
for (List<Span> pattern : patterns) {
int count = (int) patterns.stream().filter(other -> agree(pattern, other)).count();
if (count >= bestCount) {
best = pattern;
bestCount = count;
}
}
return new Circuit(bestCount >= 2 ? Confidence.CONFIRMED : Confidence.SEEN_ONCE, patterns.size(), bestCount,
best.stream().map(ProgramService::interval).toList());
}
/** Equal within one step at both ends of every span. */
private static boolean agree(List<Span> a, List<Span> b) {
if (a.size() != b.size()) {
return false;
}
for (int i = 0; i < a.size(); i++) {
if (Math.abs(a.get(i).from() - b.get(i).from()) > STEP_MINUTES
|| Math.abs(a.get(i).to() - b.get(i).to()) > STEP_MINUTES) {
return false;
}
}
return true;
}
private static Interval interval(Span span) {
return new Interval(hhmm(span.from()), hhmm(span.to()));
}
private static String hhmm(int minutes) {
return String.format("%02d:%02d", minutes / 60, minutes % 60);
}
}
@@ -25,6 +25,16 @@ import java.util.stream.Collectors;
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
public class SetpointService {
/** Longest timed preset the user may choose; the gateway's own limit is higher. */
public static final int MAX_PRESET_HOURS = 4;
/** The chosen hours are not a whole number from 1 to {@link #MAX_PRESET_HOURS}, or the preset is not timed. */
public static class InvalidHoursException extends IllegalArgumentException {
InvalidHoursException(String message) {
super(message);
}
}
private final GatewayCommands gateway;
private final List<Preset> presets;
@@ -53,12 +63,15 @@ public class SetpointService {
* 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 hours how long a timed preset keeps its changes, 1 to {@link #MAX_PRESET_HOURS}; null keeps the preset's
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
* preset's value
* @throws InvalidHoursException when {@code hours} is out of range or the preset has no hours to choose
* @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, Instant until, Map<String, Integer> values, String user)
public Optional<PresetResult> applyPreset(String id, Instant until, Integer hours, Map<String, Integer> values,
String user)
throws IOException {
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
if (preset.isEmpty()) {
@@ -68,7 +81,7 @@ public class SetpointService {
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<Change> changes = withHours(withValues(preset.get().changes(), values), hours);
List<Setpoint> limits = gateway.setpoints().setpoints();
requireWithinLimits(values, limits);
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
@@ -87,6 +100,19 @@ public class SetpointService {
c.hours())).toList();
}
private static List<Change> withHours(List<Change> changes, Integer hours) {
if (hours == null) {
return changes;
}
if (hours < 1 || hours > MAX_PRESET_HOURS) {
throw new InvalidHoursException("hours must be 1 to " + MAX_PRESET_HOURS);
}
if (changes.stream().anyMatch(c -> c.hours() == null)) {
throw new InvalidHoursException("this preset has no hours to choose");
}
return changes.stream().map(c -> new Change(c.setpoint(), c.value(), hours.doubleValue())).toList();
}
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
for (Setpoint limit : limits) {
Integer value = values.get(limit.name());
@@ -5,17 +5,18 @@ import java.util.List;
/**
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
* − charge. Values that come only from a stale source are null. {@code time} is the oldest source
* − charge. {@code housePowerW} is the whole house by balance; {@code backupOutputW} only the Deye's
* backup (EPS) output. Values that come only from a stale source are null. {@code time} is the oldest source
* time (null when none was read); {@code sources} lists Deye and Fronius with their own time and freshness.
*/
public record EnergyView(Instant time, List<Source> sources, Double pvPowerW, Double gridPowerW,
Double batteryPowerW, Double batterySocPct, Double loadPowerW, Today today) {
Double batteryPowerW, Double batterySocPct, Double housePowerW, Double backupOutputW, Today today) {
/** One inverter; time is null and stale true when the gateway has no reading of it. */
public record Source(String name, Instant time, boolean stale) {
}
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) {
/** Day totals in kWh since local midnight; PV adds Deye and Fronius, house is the energy balance. */
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double houseKwh) {
}
}
@@ -3,6 +3,6 @@ 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) {
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co,
Double outdoorTemp, Double cwuTemp, Periods periods) {
}
@@ -6,9 +6,9 @@ import java.time.Instant;
* What the heat pump does now (F1). Without a reading from the gateway {@code time} and the values are
* null; {@code stale} is true when the device is down or the reading is older than 2 × 300 s.
*/
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent, boolean heaterOn,
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent,
boolean alarm, Integer alarmCode, String alarmText, Pumps pumps, Temperatures temperatures,
Targets targets, Setpoints setpoints) {
Targets targets, Setpoints setpoints, Periods periods) {
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
@@ -0,0 +1,13 @@
package home.backend.domain;
import com.fasterxml.jackson.annotation.JsonValue;
/** Which period of the weekly program a circuit is in, read from its target (see {@link Periods}). */
public enum Period {
COMFORT, ECONOMY, UNKNOWN, OVERRIDE;
@JsonValue
public String id() {
return name().toLowerCase();
}
}
@@ -0,0 +1,50 @@
package home.backend.domain;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.Targets;
/**
* The period of CWU and room, from the target the controller works to now (verified on history,
* docs/frisko/history-2026-10.md). Only the weekly program (register 4226 = 1) switches between
* economy and comfort; any other program overrides both. {@code programText} names such a program.
*/
public record Periods(Period cwu, Period room, Integer program, String programText) {
public static final Periods UNKNOWN = new Periods(Period.UNKNOWN, Period.UNKNOWN, null, null);
private static final int WEEKLY = 1;
private static final double SAME_TARGET = 0.05;
public static Periods of(Integer program, Targets targets, Setpoints setpoints) {
if (program == null) {
return UNKNOWN;
}
return new Periods(
period(program, targets.cwu(), setpoints.cwuEconomy(), setpoints.cwuComfort()),
period(program, targets.room(), setpoints.coEconomy(), setpoints.coComfort()),
program, program == WEEKLY ? null : programText(program));
}
private static Period period(int program, Double target, Integer economy, Integer comfort) {
if (program != WEEKLY) {
return Period.OVERRIDE;
}
if (target == null || economy == null || comfort == null || economy.equals(comfort)) {
return Period.UNKNOWN;
}
if (Math.abs(target - comfort) < SAME_TARGET) {
return Period.COMFORT;
}
return Math.abs(target - economy) < SAME_TARGET ? Period.ECONOMY : Period.UNKNOWN;
}
private static String programText(int program) {
return switch (program) {
case 0 -> "Ferie";
case 2 -> "Party";
case 3 -> "Rozruch";
case 4 -> "Stop";
case 5 -> "Dezynfekcja";
default -> "Program " + program;
};
}
}
@@ -0,0 +1,35 @@
package home.backend.domain;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.List;
/**
* The weekly program as the history shows it (F6): per weekday the comfort intervals of CWU and room.
* Nothing is read from the controller's program registers. {@code weekdays} runs Monday (1) to Sunday (7).
*/
public record ProgramView(int days, List<Weekday> weekdays) {
public record Weekday(int day, Circuit cwu, Circuit room) {
}
/**
* {@code observedDays}: days of this weekday with known periods; {@code agreeingDays}: how many of them
* show {@code comfort} (within 5 minutes).
*/
public record Circuit(Confidence confidence, int observedDays, int agreeingDays, List<Interval> comfort) {
}
/** Local times "HH:MM"; an interval that runs to midnight ends at "24:00". */
public record Interval(String from, String to) {
}
public enum Confidence {
CONFIRMED, SEEN_ONCE, NO_DATA;
@JsonValue
public String id() {
return name().toLowerCase();
}
}
}
@@ -10,10 +10,10 @@ import java.util.List;
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) {
Double coHours, 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) {
Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
}
@@ -0,0 +1,672 @@
package home.backend;
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.adapters.auth.AuthSettings;
import home.backend.adapters.auth.OidcSettings;
import home.backend.adapters.auth.PasswordHash;
import home.backend.adapters.auth.StubIssuer;
import home.backend.adapters.http.ApiServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Sign-in end to end: local accounts and OIDC against a stub issuer, behind the real HTTP server. */
class AuthApiTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final String SIGNING_KEY = "0123456789abcdef0123456789abcdef-test";
private static final String PASSWORD = "correct horse";
private static final String LOGIN = "{\"username\":\"tata\",\"password\":\"" + PASSWORD + "\"}";
private static final String CALLBACK = "https://pompa.example/api/auth/oidc/callback";
private static String hash;
private final HttpClient client = HttpClient.newHttpClient();
private final List<ApiServer> servers = new ArrayList<>();
private final List<HttpServer> stubs = new ArrayList<>();
private final List<String> requestedBy = new ArrayList<>();
private StubIssuer issuer;
private ApiServer backend;
private HttpServer gateway;
@TempDir
Path dir;
@BeforeAll
static void hashOnce() {
hash = PasswordHash.create(PASSWORD.toCharArray());
}
@AfterEach
void stop() {
servers.forEach(ApiServer::stop);
stubs.forEach(s -> s.stop(0));
if (issuer != null) {
issuer.stop();
}
}
// --- set-up ---
private void launch(boolean local, boolean oidc, String role) throws IOException {
issuer = new StubIssuer();
launch(local ? users() : null, oidc ? oidcSettings(issuer.issuer(), role) : null, false);
}
private Path users() throws IOException {
return Files.writeString(dir.resolve("users.json"),
"[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]");
}
private static OidcSettings oidcSettings(URI issuer, String role) {
return new OidcSettings(issuer, StubIssuer.CLIENT_ID, StubIssuer.CLIENT_KEY, URI.create(CALLBACK), role);
}
private void launch(Path usersFile, OidcSettings oidc, boolean disabled) throws IOException {
gateway = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
gateway.createContext("/commands", this::commands);
gateway.start();
stubs.add(gateway);
URI dead = URI.create("http://127.0.0.1:1");
var settings = new Settings(URI.create("http://127.0.0.1:" + gateway.getAddress().getPort()), dead, 0,
Duration.ofSeconds(2), Duration.ofSeconds(2), "s3cret-token", null,
new AuthSettings(usersFile, oidc, SIGNING_KEY, 30, disabled, 1));
backend = Backend.assemble(settings, Clock.systemUTC());
servers.add(backend);
backend.start();
}
private void commands(HttpExchange exchange) throws IOException {
boolean post = exchange.getRequestMethod().equals("POST");
if (post) {
requestedBy.add(exchange.getRequestHeaders().getFirst("X-Requested-By"));
}
byte[] body = Files.readAllBytes(Path.of("../gateway/src/test/resources/commands/"
+ (post ? "response-applied.json" : "setpoints.json")));
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}
// --- requests ---
private HttpResponse<String> send(String method, String path, String body, String... headers) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
if (headers.length > 0) {
request.headers(headers);
}
return client.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
private HttpResponse<String> get(String path, String... headers) throws Exception {
return send("GET", path, null, headers);
}
private HttpResponse<String> post(String path, String body, String... headers) throws Exception {
var all = new ArrayList<>(List.of("Content-Type", "application/json", "X-Requested-With", "home-pump"));
all.addAll(List.of(headers));
return send("POST", path, body, all.toArray(String[]::new));
}
private static String cookie(HttpResponse<?> response, String name) {
return response.headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(name + "="))
.findFirst().map(c -> c.split(";")[0]).orElse(null);
}
private static String setCookie(HttpResponse<?> response, String name) {
return response.headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(name + "="))
.findFirst().orElse(null);
}
private String signIn() throws Exception {
return cookie(post("/api/auth/login", LOGIN), "hp_session");
}
private static JsonNode json(HttpResponse<String> response) throws IOException {
return JSON.readTree(response.body());
}
private static JsonNode example(String name) throws IOException {
return JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + name + ".json")));
}
// --- what is open and what is not ---
@Test
void configTellsWhichSignInsExist() throws Exception {
launch(true, true, null);
assertEquals(example("auth-config"), json(get("/api/auth/config")));
}
@Test
void everythingElseNeedsASession() throws Exception {
launch(true, true, null);
for (String path : List.of("/api/health", "/api/heat-pump", "/api/setpoints", "/api/audit", "/api/weather")) {
var response = get(path);
assertEquals(401, response.statusCode(), path);
assertEquals("{\"error\":\"unauthenticated\"}", response.body(), path);
}
assertEquals(401, post("/api/setpoints", "{}").statusCode());
assertEquals(401, get("/api/auth/me").statusCode());
assertEquals(200, get("/api/live").statusCode());
assertEquals("ok", get("/api/live").body());
assertEquals(404, get("/api/unknown").statusCode());
}
@Test
void sessionCookieOpensTheApiAndNamesTheRequester() throws Exception {
launch(true, false, null);
String session = signIn();
assertEquals(200, get("/api/health", "Cookie", session).statusCode());
assertEquals(200, post("/api/setpoints", "{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":2}",
"Cookie", session, "Remote-User", "intruder").statusCode());
assertEquals(List.of("tata"), requestedBy);
}
@Test
void forgedSessionCookiesAreRefused() throws Exception {
launch(true, false, null);
String session = signIn();
assertEquals(401, get("/api/health", "Cookie", session + "x").statusCode());
assertEquals(401, get("/api/health", "Cookie", "hp_session=" + session.split("\\.")[0].split("=")[1]).statusCode());
assertEquals(401, get("/api/auth/me", "Cookie", "hp_session=eyJ1c2VyIjoiYWRtaW4ifQ.AAAA").statusCode());
}
// --- local login ---
@Test
void localLoginSetsAHardenedCookieAndMeAnswers() throws Exception {
launch(true, false, null);
var login = post("/api/auth/login", LOGIN);
assertEquals(200, login.statusCode());
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"local\"}"), json(login));
String set = setCookie(login, "hp_session");
for (String flag : new String[] {"HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=2592000"}) {
assertTrue(set.contains(flag), flag);
}
var me = get("/api/auth/me", "Cookie", cookie(login, "hp_session"));
assertEquals(200, me.statusCode());
assertEquals(example("auth-me"), json(me));
}
@Test
void wrongPasswordAndUnknownUserLookAlike() throws Exception {
launch(true, false, null);
for (String body : List.of("{\"username\":\"tata\",\"password\":\"nope\"}",
"{\"username\":\"ghost\",\"password\":\"" + PASSWORD + "\"}")) {
var response = post("/api/auth/login", body);
assertEquals(401, response.statusCode());
assertEquals("{\"error\":\"invalid\"}", response.body());
assertEquals(null, setCookie(response, "hp_session"));
}
}
@Test
void fiveFailuresThrottleTheUserAndTheAddressEvenForTheRightPassword() throws Exception {
launch(true, false, null);
String wrong = "{\"username\":\"tata\",\"password\":\"nope\"}";
for (int i = 0; i < 5; i++) {
assertEquals(401, post("/api/auth/login", wrong, "X-Forwarded-For", "1.1.1." + i + ", 7.7.7.7").statusCode());
}
var user = post("/api/auth/login", LOGIN, "X-Forwarded-For", "8.8.8.8");
var address = post("/api/auth/login", "{\"username\":\"ghost\",\"password\":\"x\"}",
"X-Forwarded-For", "2.2.2.2, 7.7.7.7");
var other = post("/api/auth/login", "{\"username\":\"ghost\",\"password\":\"x\"}",
"X-Forwarded-For", "2.2.2.2, 8.8.8.8");
assertEquals(429, user.statusCode());
assertEquals("throttled", json(user).get("error").asText());
int wait = json(user).get("retryAfterSeconds").asInt();
assertTrue(wait > 14 * 60 && wait <= 15 * 60 + 1, "retry after " + wait);
assertEquals(String.valueOf(wait), user.headers().firstValue("Retry-After").orElseThrow());
assertEquals(429, address.statusCode());
assertEquals(401, other.statusCode());
}
@Test
void loginAndLogoutKeepTheWriteGuards() throws Exception {
launch(true, false, null);
assertEquals(403, post("/api/auth/login", LOGIN, "Origin", "https://evil.example").statusCode());
assertEquals(403, send("POST", "/api/auth/login", LOGIN, "Content-Type", "application/json").statusCode());
assertEquals(415, send("POST", "/api/auth/login", LOGIN, "X-Requested-With", "home-pump",
"Content-Type", "text/plain").statusCode());
assertEquals(400, post("/api/auth/login", "{}").statusCode());
assertEquals(400, post("/api/auth/login", "not json").statusCode());
assertEquals(403, send("POST", "/api/auth/logout", "{}", "Content-Type", "application/json").statusCode());
assertEquals(405, get("/api/auth/login").statusCode());
}
@Test
void sessionOfARemovedAccountStopsWorking() throws Exception {
launch(true, false, null);
String session = signIn();
assertEquals(200, get("/api/health", "Cookie", session).statusCode());
Files.writeString(dir.resolve("users.json"), "[{\"username\":\"mama\",\"passwordHash\":\"" + hash + "\"}]");
launch(dir.resolve("users.json"), null, false);
assertEquals(401, get("/api/health", "Cookie", session).statusCode());
assertEquals(401, get("/api/auth/me", "Cookie", session).statusCode());
}
@Test
void parallelWrongPasswordsAreEvaluatedAtMostFiveTimes() throws Exception {
launch(true, false, null);
var pool = java.util.concurrent.Executors.newFixedThreadPool(40);
var futures = new ArrayList<java.util.concurrent.Future<Integer>>();
for (int i = 0; i < 40; i++) {
futures.add(pool.submit(() -> post("/api/auth/login", "{\"username\":\"tata\",\"password\":\"nope\"}")
.statusCode()));
}
int evaluated = 0;
for (var future : futures) {
if (future.get() == 401) {
evaluated++;
}
}
pool.shutdown();
assertTrue(evaluated >= 1 && evaluated <= 5, "evaluated " + evaluated);
}
// --- first account and account management ---
private static final String NEW_PASSWORD = "a brand new password";
private static String account(String name, String password) {
return "{\"username\":\"" + name + "\",\"password\":\"" + password + "\"}";
}
private void launchInSetupMode() throws IOException {
launch(dir.resolve("auth-users.json"), null, false);
}
@Test
void setupCreatesTheFirstAccountOnceAndSignsItIn() throws Exception {
launchInSetupMode();
assertEquals(JSON.readTree("{\"local\":true,\"oidc\":false,\"setup\":true}"), json(get("/api/auth/config")));
var created = post("/api/auth/setup", account("tata", NEW_PASSWORD));
assertEquals(200, created.statusCode());
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"local\"}"), json(created));
assertEquals(200, get("/api/health", "Cookie", cookie(created, "hp_session")).statusCode());
assertEquals(JSON.readTree("{\"local\":true,\"oidc\":false,\"setup\":false}"), json(get("/api/auth/config")));
var second = post("/api/auth/setup", account("mama", NEW_PASSWORD));
assertEquals(409, second.statusCode());
assertEquals("{\"error\":\"setup_done\"}", second.body());
assertEquals(200, post("/api/auth/login", account("tata", NEW_PASSWORD)).statusCode());
assertEquals(401, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
}
@Test
void setupWithAWeakPasswordOrOddNameIsRefusedAndCreatesNothing() throws Exception {
launchInSetupMode();
var weak = post("/api/auth/setup", account("tata", "short"));
var odd = post("/api/auth/setup", account("a b", NEW_PASSWORD));
assertEquals(400, weak.statusCode());
assertEquals("{\"error\":\"weak_password\"}", weak.body());
assertEquals(400, odd.statusCode());
assertEquals(true, json(get("/api/auth/config")).get("setup").asBoolean());
assertFalse(Files.exists(dir.resolve("auth-users.json")));
}
@Test
void setupKeepsTheWriteGuards() throws Exception {
launchInSetupMode();
assertEquals(403, post("/api/auth/setup", account("tata", NEW_PASSWORD), "Origin", "https://evil.example")
.statusCode());
assertEquals(403, send("POST", "/api/auth/setup", account("tata", NEW_PASSWORD), "Content-Type",
"application/json").statusCode());
assertEquals(415, send("POST", "/api/auth/setup", account("tata", NEW_PASSWORD), "X-Requested-With",
"home-pump", "Content-Type", "text/plain").statusCode());
}
@Test
void setupIsNotFoundWithoutLocalAccounts() throws Exception {
launch(false, true, null);
assertEquals(404, post("/api/auth/setup", account("tata", NEW_PASSWORD)).statusCode());
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":true,\"setup\":false}"), json(get("/api/auth/config")));
}
@Test
void twoParallelSetupsLeaveExactlyOneWinner() throws Exception {
launchInSetupMode();
var pool = java.util.concurrent.Executors.newFixedThreadPool(2);
var a = pool.submit(() -> post("/api/auth/setup", account("tata", NEW_PASSWORD)).statusCode());
var b = pool.submit(() -> post("/api/auth/setup", account("mama", NEW_PASSWORD)).statusCode());
var codes = new java.util.TreeSet<>(List.of(a.get(), b.get()));
pool.shutdown();
assertEquals(new java.util.TreeSet<>(List.of(200, 409)), codes);
assertEquals(1, JSON.readTree(Files.readString(dir.resolve("auth-users.json"))).size());
}
@Test
void accountManagementNeedsASession() throws Exception {
launch(true, false, null);
assertEquals(401, get("/api/auth/users").statusCode());
assertEquals(401, post("/api/auth/users", account("mama", NEW_PASSWORD)).statusCode());
assertEquals(401, post("/api/auth/password", "{\"current\":\"x\",\"new\":\"" + NEW_PASSWORD + "\"}")
.statusCode());
assertEquals(401, post("/api/auth/users/tata/delete", "{}").statusCode());
}
@Test
void usersCanBeListedAddedAndRemovedButNotTheLastOne() throws Exception {
launch(true, false, null);
String session = signIn();
assertEquals(JSON.readTree("[{\"username\":\"tata\"}]"), json(get("/api/auth/users", "Cookie", session)));
assertEquals(200, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
assertEquals(409, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
assertEquals(400, post("/api/auth/users", account("zosia", "short"), "Cookie", session).statusCode());
assertEquals(2, json(get("/api/auth/users", "Cookie", session)).size());
assertEquals(200, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
assertEquals(204, post("/api/auth/users/mama/delete", "{}", "Cookie", session).statusCode());
assertEquals(404, post("/api/auth/users/mama/delete", "{}", "Cookie", session).statusCode());
var last = post("/api/auth/users/tata/delete", "{}", "Cookie", session);
assertEquals(409, last.statusCode());
assertEquals("{\"error\":\"last_user\"}", last.body());
assertEquals(403, send("POST", "/api/auth/users/mama/delete", "{}", "Cookie", session,
"Content-Type", "application/json").statusCode());
}
@Test
void anOidcSessionMayManageAccountsButNotChangeAPassword() throws Exception {
launch(true, true, null);
Started started = start();
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
String session = cookie(callbackWithCode(started), "hp_session");
assertEquals(200, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
assertEquals(403, post("/api/auth/password", "{\"current\":\"x\",\"new\":\"" + NEW_PASSWORD + "\"}",
"Cookie", session).statusCode());
}
@Test
void removedAccountLosesItsSessionAtOnceAndRemovingYourselfSignsYouOut() throws Exception {
launch(true, false, null);
String tata = signIn();
post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", tata);
String mama = cookie(post("/api/auth/login", account("mama", NEW_PASSWORD)), "hp_session");
assertEquals(200, get("/api/health", "Cookie", mama).statusCode());
assertEquals(204, post("/api/auth/users/mama/delete", "{}", "Cookie", tata).statusCode());
assertEquals(401, get("/api/health", "Cookie", mama).statusCode());
post("/api/auth/users", account("zosia", NEW_PASSWORD), "Cookie", tata);
var self = post("/api/auth/users/tata/delete", "{}", "Cookie", tata);
assertEquals(204, self.statusCode());
assertTrue(setCookie(self, "hp_session").startsWith("hp_session=; Max-Age=0"));
assertEquals(401, get("/api/health", "Cookie", tata).statusCode());
}
@Test
void ownPasswordChangeNeedsTheCurrentOneAndCountsFailuresInTheThrottle() throws Exception {
launch(true, false, null);
String session = signIn();
String wrong = "{\"current\":\"nope\",\"new\":\"" + NEW_PASSWORD + "\"}";
assertEquals(400, post("/api/auth/password", "{\"current\":\"" + PASSWORD + "\",\"new\":\"short\"}",
"Cookie", session).statusCode());
var rejected = post("/api/auth/password", wrong, "Cookie", session);
assertEquals(401, rejected.statusCode());
assertEquals("{\"error\":\"invalid\"}", rejected.body());
assertEquals(204, post("/api/auth/password", "{\"current\":\"" + PASSWORD + "\",\"new\":\""
+ NEW_PASSWORD + "\"}", "Cookie", session).statusCode());
assertEquals(401, post("/api/auth/login", LOGIN).statusCode());
assertEquals(200, post("/api/auth/login", account("tata", NEW_PASSWORD)).statusCode());
for (int i = 0; i < 5; i++) {
post("/api/auth/password", wrong, "Cookie", session);
}
assertEquals(429, post("/api/auth/password", wrong, "Cookie", session).statusCode());
}
@Test
void changesSurviveARestartBecauseTheFileIsRewritten() throws Exception {
launch(true, false, null);
String session = signIn();
post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session);
launch(dir.resolve("users.json"), null, false);
assertEquals(200, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
}
@Test
void logoutClearsTheCookie() throws Exception {
launch(true, false, null);
var response = post("/api/auth/logout", "{}", "Cookie", signIn());
assertEquals(204, response.statusCode());
assertTrue(setCookie(response, "hp_session").startsWith("hp_session=; Max-Age=0"));
}
@Test
void loginIsNotFoundWithoutLocalAccounts() throws Exception {
launch(false, true, null);
assertEquals(404, post("/api/auth/login", LOGIN).statusCode());
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":true,\"setup\":false}"), json(get("/api/auth/config")));
}
@Test
void disabledAuthenticationOpensTheApiAsPhone() throws Exception {
launch(null, null, true);
assertEquals(200, get("/api/health").statusCode());
assertEquals(JSON.readTree("{\"user\":\"phone\",\"method\":\"none\"}"), json(get("/api/auth/me")));
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":false,\"setup\":false}"), json(get("/api/auth/config")));
assertEquals(204, post("/api/auth/logout", "{}").statusCode());
}
// --- OIDC ---
private record Started(Map<String, String> query, String transaction) {
}
private Started start() throws Exception {
var response = get("/api/auth/oidc/start");
assertEquals(302, response.statusCode());
URI location = URI.create(response.headers().firstValue("Location").orElseThrow());
assertEquals(issuer.issuer() + "/protocol/openid-connect/auth", location.toString().split("\\?")[0]);
String set = setCookie(response, "hp_oidc");
assertTrue(set.contains("HttpOnly") && set.contains("Secure") && set.contains("SameSite=Lax")
&& set.contains("Max-Age=600"), set);
return new Started(query(location.getRawQuery()), cookie(response, "hp_oidc"));
}
private static Map<String, String> query(String raw) {
Map<String, String> query = new HashMap<>();
for (String pair : raw.split("&")) {
String[] kv = pair.split("=", 2);
query.put(kv[0], URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
}
return query;
}
private HttpResponse<String> callback(Started started, String queryString) throws Exception {
return get("/api/auth/oidc/callback?" + queryString, "Cookie", started.transaction());
}
private HttpResponse<String> callbackWithCode(Started started) throws Exception {
return callback(started, "code=abc&state=" + started.query().get("state"));
}
@Test
void oidcLoginSendsTheRightRequestAndSignsTheUserIn() throws Exception {
launch(false, true, "home-pump");
Started started = start();
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
var response = callbackWithCode(started);
assertEquals(302, response.statusCode());
assertEquals("/", response.headers().firstValue("Location").orElseThrow());
assertTrue(setCookie(response, "hp_oidc").startsWith("hp_oidc=; Max-Age=0"));
var me = get("/api/auth/me", "Cookie", cookie(response, "hp_session"));
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"oidc\"}"), json(me));
var authorize = started.query();
assertEquals("code", authorize.get("response_type"));
assertEquals(StubIssuer.CLIENT_ID, authorize.get("client_id"));
assertEquals(CALLBACK, authorize.get("redirect_uri"));
assertEquals("S256", authorize.get("code_challenge_method"));
assertTrue(authorize.get("scope").contains("openid"));
var token = issuer.tokenRequests.getFirst();
assertEquals("Basic " + Base64.getEncoder().encodeToString(
(StubIssuer.CLIENT_ID + ":" + StubIssuer.CLIENT_KEY).getBytes(StandardCharsets.UTF_8)),
token.authorization());
assertEquals("authorization_code", token.form().get("grant_type"));
assertEquals("abc", token.form().get("code"));
assertEquals(CALLBACK, token.form().get("redirect_uri"));
assertEquals(authorize.get("code_challenge"), Base64.getUrlEncoder().withoutPadding().encodeToString(
MessageDigest.getInstance("SHA-256").digest(
token.form().get("code_verifier").getBytes(StandardCharsets.US_ASCII))));
}
@Test
void oidcTokensThatDoNotVerifyEndInOidcFailed() throws Exception {
launch(false, true, null);
long now = Instant.now().getEpochSecond();
List<Function<String, String>> bad = List.of(
nonce -> StubIssuer.sign("RS256", "k1", Map.of("iss", issuer.issuer().toString(),
"aud", StubIssuer.CLIENT_ID, "nonce", nonce, "iat", now, "exp", now + 300,
"preferred_username", "tata"), StubIssuer.newKeys().getPrivate()),
nonce -> issuer.idToken(nonce, c -> c.put("aud", "other")),
nonce -> issuer.idToken(nonce, c -> c.put("exp", now - 600)),
nonce -> issuer.idToken("another-nonce", c -> { }));
for (var token : bad) {
Started started = start();
issuer.idTokenToServe = token.apply(started.query().get("nonce"));
var response = callbackWithCode(started);
assertEquals("/#login?error=oidc_failed", response.headers().firstValue("Location").orElseThrow());
assertEquals(null, cookie(response, "hp_session"));
}
}
@Test
void wrongStateOrMissingLoginIsRefusedWithoutAskingTheIssuer() throws Exception {
launch(false, true, null);
Started started = start();
var wrongState = callback(started, "code=abc&state=forged");
var noCookie = get("/api/auth/oidc/callback?code=abc&state=" + started.query().get("state"));
var noParameters = callback(started, "");
for (var response : List.of(wrongState, noCookie, noParameters)) {
assertEquals("/#login?error=oidc_failed", response.headers().firstValue("Location").orElseThrow());
}
assertTrue(issuer.tokenRequests.isEmpty());
}
@Test
void missingRoleIsDenied() throws Exception {
launch(false, true, "admin");
Started started = start();
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
var response = callbackWithCode(started);
assertEquals("/#login?error=oidc_denied", response.headers().firstValue("Location").orElseThrow());
assertEquals(null, cookie(response, "hp_session"));
}
@Test
void refusedByTheUserIsDenied() throws Exception {
launch(false, true, null);
Started started = start();
var response = callback(started, "error=access_denied&state=" + started.query().get("state"));
assertEquals("/#login?error=oidc_denied", response.headers().firstValue("Location").orElseThrow());
}
@Test
void tokenEndpointRefusalIsOidcFailed() throws Exception {
launch(false, true, null);
Started started = start();
issuer.tokenStatus = 400;
assertEquals("/#login?error=oidc_failed",
callbackWithCode(started).headers().firstValue("Location").orElseThrow());
}
@Test
void keycloakDownBeforeTheStartRedirectsBackWhileLocalLoginWorks() throws Exception {
issuer = new StubIssuer();
URI gone = issuer.issuer();
issuer.stop();
launch(users(), oidcSettings(gone, null), false);
var start = get("/api/auth/oidc/start");
assertEquals(302, start.statusCode());
assertEquals("/#login?error=oidc_unavailable", start.headers().firstValue("Location").orElseThrow());
assertEquals(null, cookie(start, "hp_oidc"));
assertEquals(200, get("/api/auth/config").statusCode());
assertTrue(signIn() != null);
}
@Test
void keycloakDownDuringTheCallbackIsUnavailable() throws Exception {
launch(true, true, null);
Started started = start();
issuer.stop();
var response = callbackWithCode(started);
assertEquals("/#login?error=oidc_unavailable", response.headers().firstValue("Location").orElseThrow());
assertEquals(200, post("/api/auth/login", LOGIN).statusCode());
}
}
@@ -0,0 +1,96 @@
package home.backend;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
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;
/** The environment rules that keep the backend from starting open by accident. */
class AuthSettingsTest {
private static final String SIGNING_KEY = "0123456789abcdef0123456789abcdef";
private static Map<String, String> env(String... extra) {
Map<String, String> env = new HashMap<>(Map.of("GATEWAY_URL", "http://10.0.0.2:8080",
"INFLUX_URL", "https://influx.example"));
for (int i = 0; i < extra.length; i += 2) {
env.put(extra[i], extra[i + 1]);
}
return env;
}
private static String refusal(String... extra) {
return assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(env(extra))).getMessage();
}
@Test
void withoutAnySignInTheBackendRefusesToStart() {
assertTrue(refusal().contains("AUTH_DISABLED=true"));
assertTrue(refusal("AUTH_DISABLED", "false").contains("no sign-in configured"));
}
@Test
void authDisabledIsAnExplicitChoice() {
var auth = Settings.fromEnvironment(env("AUTH_DISABLED", "true")).auth();
assertTrue(auth.disabled());
assertNull(auth.oidc());
}
@Test
void disabledContradictsConfiguredSignIn() {
assertTrue(refusal("AUTH_DISABLED", "true", "USERS_FILE", "/data/users.json").contains("contradicts"));
}
@Test
void configuredSignInNeedsALongSessionSecret() {
assertTrue(refusal("USERS_FILE", "/data/users.json").contains("SESSION_SECRET"));
assertTrue(refusal("USERS_FILE", "/data/users.json", "SESSION_SECRET", "short").contains("32 bytes"));
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "OIDC_CLIENT_ID", "x",
"OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb").contains("SESSION_SECRET"));
}
@Test
void oidcWithoutARequiredRoleWarns() {
var open = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
"OIDC_CLIENT_ID", "x", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb",
"SESSION_SECRET", SIGNING_KEY)).auth();
var restricted = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
"OIDC_CLIENT_ID", "x", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb",
"OIDC_REQUIRED_ROLE", "family", "SESSION_SECRET", SIGNING_KEY)).auth();
assertTrue(open.warnings().getFirst().contains("every user of the Keycloak realm"));
assertTrue(restricted.warnings().isEmpty());
}
@Test
void oidcNeedsItsClientSettings() {
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "SESSION_SECRET", SIGNING_KEY)
.contains("OIDC_CLIENT_ID"));
}
@Test
void localAndOidcAreConfiguredIndependently() {
var local = Settings.fromEnvironment(env("USERS_FILE", "/data/users.json", "SESSION_SECRET", SIGNING_KEY)).auth();
var oidc = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
"OIDC_CLIENT_ID", "home-pump", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI",
"https://pompa.example/api/auth/oidc/callback", "OIDC_REQUIRED_ROLE", "family",
"SESSION_SECRET", SIGNING_KEY, "SESSION_DAYS", "7", "TRUSTED_PROXY_HOPS", "2")).auth();
assertEquals("/data/users.json", local.usersFile().toString());
assertNull(local.oidc());
assertEquals(30, local.sessionDays());
assertEquals(1, local.proxyHops());
assertNull(oidc.usersFile());
assertEquals("family", oidc.oidc().requiredRole());
assertEquals(7, oidc.sessionDays());
assertEquals(2, oidc.proxyHops());
assertFalse(oidc.disabled());
}
}
@@ -4,6 +4,7 @@ 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.adapters.auth.AuthSettings;
import home.backend.adapters.http.ApiServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -43,6 +44,8 @@ class BackendTest {
/** Just after the readings in the snapshot fixture, so they count as fresh. */
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC);
private static final AuthSettings OPEN = new AuthSettings(null, null, null, 30, true, 1);
private final HttpClient client = HttpClient.newHttpClient();
private HttpServer gateway;
private HttpServer influx;
@@ -56,7 +59,7 @@ class BackendTest {
gateway.createContext("/commands", this::commands);
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
"s3cret-token", null);
"s3cret-token", null, OPEN);
backend = Backend.assemble(settings, CLOCK);
backend.start();
}
@@ -83,7 +86,7 @@ class BackendTest {
private HttpResponse<String> post(String path, String body) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
.header("Remote-User", "anna").header("Content-Type", "application/json")
.header("Content-Type", "application/json")
.header("X-Requested-With", "home-pump")
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
@@ -199,7 +202,7 @@ class BackendTest {
JSON.readTree(response.body()));
assertEquals(1, commandsSeen.size());
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
assertEquals("anna", commandsSeen.getFirst().requestedBy());
assertEquals("phone", commandsSeen.getFirst().requestedBy());
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
JSON.readTree(commandsSeen.getFirst().body()));
}
@@ -209,7 +212,7 @@ class BackendTest {
Path file = Files.writeString(dir.resolve("presets.json"),
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
Duration.ofSeconds(2), "s3cret-token", file), CLOCK);
Duration.ofSeconds(2), "s3cret-token", file, OPEN), CLOCK);
custom.start();
try {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
@@ -229,7 +232,7 @@ class BackendTest {
@Test
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
Duration.ofSeconds(2), null, null), CLOCK);
Duration.ofSeconds(2), null, null, OPEN), CLOCK);
noToken.start();
try {
var base = "http://127.0.0.1:" + noToken.port();
@@ -256,7 +259,7 @@ class BackendTest {
@Test
void urlsMayEndWithASingleSlash() {
var settings = Settings.fromEnvironment(
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/"));
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/", "AUTH_DISABLED", "true"));
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
}
@@ -264,7 +267,7 @@ class BackendTest {
@Test
void settingsHaveDefaults() {
var settings = Settings.fromEnvironment(
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example"));
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example", "AUTH_DISABLED", "true"));
assertEquals(8080, settings.port());
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
@@ -273,7 +276,8 @@ class BackendTest {
@Test
void tokenAndPresetsFileAreOptional() {
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example",
"AUTH_DISABLED", "true");
var none = Settings.fromEnvironment(urls);
var env = new HashMap<>(urls);
env.put("GATEWAY_TOKEN", " s3cret-token ");
@@ -0,0 +1,151 @@
package home.backend.adapters.auth;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URI;
import java.time.Clock;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class IdTokenVerifierTest {
private static StubIssuer issuer;
@BeforeAll
static void start() throws IOException {
issuer = new StubIssuer();
}
@AfterAll
static void stop() {
issuer.stop();
}
private IdTokenVerifier verifier(String role) {
return new IdTokenVerifier(new OidcSettings(issuer.issuer(), StubIssuer.CLIENT_ID, "x",
URI.create("https://pompa.example/api/auth/oidc/callback"), role), Clock.systemUTC());
}
private String verify(String token, String role) throws OidcException {
return verifier(role).verify(token, "n1", kid -> kid.equals("k1") ? issuer.publicKey() : null);
}
private OidcException failure(String token, String role) {
return assertThrows(OidcException.class, () -> verify(token, role));
}
private String token(Consumer<Map<String, Object>> change) {
return issuer.idToken("n1", change);
}
@Test
void validTokenGivesTheUserName() throws Exception {
assertEquals("tata", verify(token(c -> { }), "home-pump"));
}
@Test
void userNameFallsBackToEmailThenSubject() throws Exception {
assertEquals("tata@example.pl", verify(token(c -> {
c.remove("preferred_username");
c.put("email", "tata@example.pl");
}), null));
assertEquals("7f1c", verify(token(c -> c.remove("preferred_username")), null));
}
@Test
void signatureOfAnotherKeyIsRefused() {
var forged = StubIssuer.sign("RS256", "k1", Map.of("iss", issuer.issuer().toString(), "aud", "home-pump"),
StubIssuer.newKeys().getPrivate());
assertEquals(FAILED, failure(forged, null).reason());
}
@Test
void tamperedPayloadIsRefused() {
String[] parts = token(c -> { }).split("\\.");
String other = token(c -> c.put("preferred_username", "admin")).split("\\.")[1];
assertEquals(FAILED, failure(parts[0] + "." + other + "." + parts[2], null).reason());
}
@Test
void otherAlgorithmsAreRefused() {
String none = "eyJhbGciOiJub25lIn0." + token(c -> { }).split("\\.")[1] + ".";
assertEquals(FAILED, failure(none, null).reason());
}
@Test
void tokenForAnotherClientIsRefused() {
assertEquals(FAILED, failure(token(c -> c.put("aud", "other-app")), null).reason());
}
@Test
void severalAudiencesNeedOurClientAsAuthorizedParty() throws Exception {
assertEquals(FAILED, failure(token(c -> c.put("aud", List.of("home-pump", "account"))), null).reason());
assertEquals("tata", verify(token(c -> {
c.put("aud", List.of("home-pump", "account"));
c.put("azp", "home-pump");
}), null));
}
@Test
void wrongIssuerIsRefused() {
assertEquals(FAILED, failure(token(c -> c.put("iss", "https://evil.example/realms/home")), null).reason());
}
@Test
void expiredTokenIsRefusedButASmallSkewIsAllowed() throws Exception {
long now = java.time.Instant.now().getEpochSecond();
assertEquals(FAILED, failure(token(c -> c.put("exp", now - 120)), null).reason());
assertEquals("tata", verify(token(c -> c.put("exp", now - 10)), null));
}
@Test
void tokenIssuedInTheFutureIsRefused() {
long now = java.time.Instant.now().getEpochSecond();
assertEquals(FAILED, failure(token(c -> c.put("iat", now + 600)), null).reason());
}
@Test
void wrongNonceIsRefused() {
assertEquals(FAILED, failure(issuer.idToken("another", c -> { }), null).reason());
}
@Test
void requiredRoleMayBeARealmRoleOrAClientRole() throws Exception {
assertEquals("tata", verify(token(c -> { }), "home-pump"));
assertEquals("tata", verify(token(c -> {
c.remove("realm_access");
c.put("resource_access", Map.of("home-pump", Map.of("roles", List.of("family"))));
}), "family"));
}
@Test
void missingRoleIsDenied() {
assertEquals(DENIED, failure(token(c -> { }), "admin").reason());
assertEquals(DENIED, failure(token(c -> c.put("resource_access",
Map.of("account", Map.of("roles", List.of("admin"))))), "admin").reason());
}
@Test
void unknownKeyIsRefused() {
var verifier = verifier(null);
assertEquals(FAILED, assertThrows(OidcException.class,
() -> verifier.verify(token(c -> { }), "n1", kid -> {
throw new OidcException(FAILED, "no signing key " + kid);
})).reason());
}
}
@@ -0,0 +1,164 @@
package home.backend.adapters.auth;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Hashing, the accounts file and the hash-password command. */
class PasswordTest {
private static String hash;
@BeforeAll
static void hashOnce() {
hash = PasswordHash.create("correct horse".toCharArray());
}
@Test
void hashIsSelfDescribingSaltedAndChecked() {
String[] parts = hash.split("\\$");
assertEquals("pbkdf2-sha256", parts[0]);
assertTrue(Integer.parseInt(parts[1]) >= 310_000);
assertTrue(PasswordHash.matches(hash, "correct horse".toCharArray()));
assertFalse(PasswordHash.matches(hash, "wrong horse".toCharArray()));
assertNotEquals(hash, PasswordHash.create("correct horse".toCharArray()));
}
@Test
void weakOrMalformedHashesAreRefused() {
for (String bad : new String[] {"", "plain", "pbkdf2-sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA",
"pbkdf2-sha256$x$y$z", "md5$600000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA"}) {
assertThrows(IllegalArgumentException.class, () -> PasswordHash.requireValid(bad), bad);
}
}
@Test
void usersFileHoldsAccounts(@TempDir Path dir) throws IOException {
UserStore users = UserStore.load(file(dir, "[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]"));
assertTrue(users.authenticate("tata", "correct horse".toCharArray()));
assertFalse(users.authenticate("tata", "nope".toCharArray()));
assertFalse(users.authenticate("ghost", "correct horse".toCharArray()));
}
@Test
void badUsersFilesStopTheStart(@TempDir Path dir) throws IOException {
String account = "{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}";
for (String bad : new String[] {"not json", "[" + account + "," + account + "]",
"[{\"username\":\"a b\",\"passwordHash\":\"" + hash + "\"}]",
"[{\"username\":\"tata\",\"passwordHash\":\"plain\"}]"}) {
Path file = file(dir, bad);
assertThrows(IllegalArgumentException.class, () -> UserStore.load(file), bad);
}
}
@Test
void missingOrEmptyFileIsAnEmptyStoreForSetup(@TempDir Path dir) throws IOException {
assertTrue(UserStore.load(dir.resolve("missing.json")).isEmpty());
assertTrue(UserStore.load(file(dir, "[]")).isEmpty());
}
@Test
void changesAreWrittenAtomicallyPrivatelyAndReadBack(@TempDir Path dir) throws IOException {
Path file = dir.resolve("users.json");
UserStore users = UserStore.load(file);
assertTrue(users.addFirst("tata", hash));
assertFalse(users.addFirst("mama", hash));
assertTrue(users.add("mama", hash));
assertFalse(users.add("mama", hash));
assertTrue(users.changePassword("mama", PasswordHash.create("another password".toCharArray())));
assertFalse(users.changePassword("ghost", hash));
assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file)));
try (var listing = Files.list(dir)) {
assertEquals(List.of("users.json"), listing.map(p -> p.getFileName().toString()).toList());
}
UserStore again = UserStore.load(file);
assertEquals(List.of("tata", "mama"), again.usernames());
assertTrue(again.authenticate("tata", "correct horse".toCharArray()));
assertTrue(again.authenticate("mama", "another password".toCharArray()));
}
@Test
void lastAccountCannotBeRemoved(@TempDir Path dir) {
UserStore users = UserStore.load(dir.resolve("users.json"));
users.addFirst("tata", hash);
users.add("mama", hash);
assertEquals(UserStore.Removal.REMOVED, users.remove("mama"));
assertEquals(UserStore.Removal.LAST, users.remove("tata"));
assertEquals(UserStore.Removal.UNKNOWN, users.remove("mama"));
assertEquals(List.of("tata"), UserStore.load(dir.resolve("users.json")).usernames());
}
@Test
void failedWriteLeavesTheStoreAsItWas(@TempDir Path dir) {
UserStore users = UserStore.load(dir.resolve("gone/users.json"));
assertThrows(UncheckedIOException.class, () -> users.addFirst("tata", hash));
assertTrue(users.isEmpty());
}
/** An unknown user is run through the same hashing, so the answer does not tell which names exist. */
@Test
void unknownUserCostsAboutAsMuchAsAWrongPassword(@TempDir Path dir) throws IOException {
UserStore users = UserStore.load(file(dir, "[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]"));
users.authenticate("tata", "warm up".toCharArray());
long wrong = nanos(() -> users.authenticate("tata", "nope".toCharArray()));
long unknown = nanos(() -> users.authenticate("ghost", "nope".toCharArray()));
assertTrue(unknown > wrong / 2, "unknown " + unknown + " ns vs wrong " + wrong + " ns");
}
@Test
void commandPrintsAHashFromStdin() throws IOException {
var out = new ByteArrayOutputStream();
int code = PasswordHashCli.run(null, stream("s3cr3t-pass\n"), new PrintStream(out), System.err);
assertEquals(0, code);
assertTrue(PasswordHash.matches(out.toString(StandardCharsets.UTF_8).trim(), "s3cr3t-pass".toCharArray()));
}
@Test
void commandRefusesAnEmptyPassword() throws IOException {
var err = new ByteArrayOutputStream();
assertEquals(1, PasswordHashCli.run(null, stream("\n"), System.out, new PrintStream(err)));
assertTrue(err.toString(StandardCharsets.UTF_8).contains("No password"));
}
private static ByteArrayInputStream stream(String text) {
return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8));
}
private static Path file(Path dir, String content) throws IOException {
return Files.writeString(dir.resolve("users-" + content.hashCode() + ".json"), content);
}
private static long nanos(Runnable action) {
long start = System.nanoTime();
action.run();
return System.nanoTime() - start;
}
}
@@ -0,0 +1,153 @@
package home.backend.adapters.auth;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The session cookie, the throttle and the other signed cookie. */
class SessionsTest {
private static final byte[] SIGNING_KEY = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
private static final Instant T0 = Instant.parse("2026-10-24T10:00:00Z");
private Instant now = T0;
private final Clock clock = new Clock() {
@Override
public ZoneOffset getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(java.time.ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return now;
}
};
private final SignedTokens tokens = new SignedTokens(SIGNING_KEY, clock);
private final Sessions sessions = new Sessions(tokens, clock, Duration.ofDays(30));
private static String cookieHeader(String setCookie) {
return setCookie.split(";")[0];
}
@Test
void cookieCarriesTheSessionAndTheRightFlags() {
String setCookie = sessions.setCookie("tata", "local");
assertEquals(Optional.of(new Session("tata", "local", T0)), sessions.read(cookieHeader(setCookie)));
for (String flag : new String[] {"HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=2592000"}) {
assertTrue(setCookie.contains(flag), flag);
}
}
@Test
void sessionExpiresAfterItsLifetime() {
String cookie = cookieHeader(sessions.setCookie("tata", "oidc"));
now = T0.plus(Duration.ofDays(30)).minusSeconds(1);
assertTrue(sessions.read(cookie).isPresent());
now = T0.plus(Duration.ofDays(30));
assertTrue(sessions.read(cookie).isEmpty());
}
@Test
void tamperedOrForeignCookiesAreRefused() {
String cookie = cookieHeader(sessions.setCookie("tata", "local"));
String other = cookieHeader(sessions.setCookie("mama", "local"));
String forgedPayload = other.substring(0, other.indexOf('.')) + cookie.substring(cookie.indexOf('.'));
var otherSecret = new Sessions(new SignedTokens("another-secret-another-secret-12".getBytes(StandardCharsets.UTF_8),
clock), clock, Duration.ofDays(30));
assertTrue(sessions.read(forgedPayload).isEmpty());
assertTrue(sessions.read(cookie + "x").isEmpty());
assertTrue(sessions.read("hp_session=garbage").isEmpty());
assertTrue(sessions.read("hp_session=a.b.c").isEmpty());
assertTrue(sessions.read(null).isEmpty());
assertTrue(otherSecret.read(cookie).isEmpty());
}
@Test
void aTokenForAnotherPurposeIsNotASession() {
String token = tokens.issue("oidc-login", Duration.ofMinutes(10),
Map.of("user", "tata", "method", "local", "iat", "0"));
assertTrue(sessions.read("hp_session=" + token).isEmpty());
}
@Test
void clearingExpiresTheCookie() {
assertTrue(sessions.clearCookie().startsWith("hp_session=; Max-Age=0"));
}
@Test
void throttleBlocksTheSixthAttemptUntilTheOldestLeavesTheWindow() {
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
for (int i = 0; i < 4; i++) {
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
}
now = T0.plusSeconds(60);
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
assertEquals(15 * 60 - 60 + 1, throttle.tryAcquire("user:tata"));
assertEquals(0, throttle.tryAcquire("user:mama", "ip:5.6.7.8"));
now = T0.plus(Duration.ofMinutes(15));
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
}
@Test
void releasedAttemptsAndSuccessDoNotCount() {
var throttle = new LoginThrottle(clock, 2, Duration.ofMinutes(15));
throttle.tryAcquire("user:tata");
throttle.release("user:tata");
throttle.tryAcquire("user:tata");
assertEquals(0, throttle.tryAcquire("user:tata"));
assertTrue(throttle.tryAcquire("user:tata") > 0);
throttle.cleared("user:tata");
assertEquals(0, throttle.tryAcquire("user:tata"));
}
@Test
void parallelAttemptsCannotOutrunTheLimit() throws Exception {
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
var pool = java.util.concurrent.Executors.newFixedThreadPool(32);
var allowed = new java.util.concurrent.atomic.AtomicInteger();
var tasks = new java.util.ArrayList<java.util.concurrent.Future<?>>();
for (int i = 0; i < 200; i++) {
tasks.add(pool.submit(() -> {
if (throttle.tryAcquire("user:tata", "ip:1.2.3.4") == 0) {
allowed.incrementAndGet();
}
}));
}
for (var task : tasks) {
task.get();
}
pool.shutdown();
assertEquals(5, allowed.get());
}
@Test
void odditiesInTheProvidersErrorAreCleanedForTheLog() {
assertEquals("server_error", OidcFlow.printable("server_error"));
assertEquals("a?b?c", OidcFlow.printable("a\nb\rc"));
assertEquals(40 + 3, OidcFlow.printable("x".repeat(100)).length());
assertEquals("nothing", OidcFlow.printable(null));
}
}
@@ -0,0 +1,140 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/** A Keycloak stand-in on localhost: discovery, signing keys and a token endpoint that hands out what a test says. */
public final class StubIssuer {
public static final String CLIENT_ID = "home-pump";
public static final String CLIENT_KEY = "stub-client-secret";
private static final ObjectMapper JSON = new ObjectMapper();
/** What the token endpoint received. */
public record TokenRequest(String authorization, Map<String, String> form) {
}
public final List<TokenRequest> tokenRequests = new ArrayList<>();
/** What the token endpoint answers with; set by the test. */
public volatile String idTokenToServe = "";
public volatile int tokenStatus = 200;
private final KeyPair keys = newKeys();
private final HttpServer server;
public StubIssuer() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/realms/home/.well-known/openid-configuration", exchange -> reply(exchange, 200,
Map.of("issuer", issuer().toString(),
"authorization_endpoint", issuer() + "/protocol/openid-connect/auth",
"token_endpoint", issuer() + "/protocol/openid-connect/token",
"jwks_uri", issuer() + "/protocol/openid-connect/certs")));
server.createContext("/realms/home/protocol/openid-connect/certs", exchange -> reply(exchange, 200,
Map.of("keys", List.of(Map.of("kty", "RSA", "kid", "broken", "n", "***", "e", "AQAB"), jwk()))));
server.createContext("/realms/home/protocol/openid-connect/token", this::token);
server.start();
}
public URI issuer() {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/realms/home");
}
public java.security.PublicKey publicKey() {
return keys.getPublic();
}
public void stop() {
server.stop(0);
}
/** A valid ID token for the nonce, with {@code change} applied to the claims. */
public String idToken(String nonce, Consumer<Map<String, Object>> change) {
long now = Instant.now().getEpochSecond();
Map<String, Object> claims = new LinkedHashMap<>();
claims.put("iss", issuer().toString());
claims.put("aud", CLIENT_ID);
claims.put("sub", "7f1c");
claims.put("preferred_username", "tata");
claims.put("iat", now);
claims.put("exp", now + 300);
claims.put("nonce", nonce);
claims.put("realm_access", Map.of("roles", List.of("home-pump")));
change.accept(claims);
return sign("RS256", "k1", claims, keys.getPrivate());
}
/** Signs with any key, so a test can forge a token. */
public static String sign(String alg, String kid, Map<String, Object> claims, PrivateKey key) {
try {
String header = Base64Url.encode(JSON.writeValueAsBytes(Map.of("alg", alg, "kid", kid, "typ", "JWT")));
String signed = header + "." + Base64Url.encode(JSON.writeValueAsBytes(claims));
Signature rsa = Signature.getInstance("SHA256withRSA");
rsa.initSign(key);
rsa.update(signed.getBytes(StandardCharsets.US_ASCII));
return signed + "." + Base64Url.encode(rsa.sign());
} catch (IOException | GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
public static KeyPair newKeys() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
private Map<String, String> jwk() {
var key = (RSAPublicKey) keys.getPublic();
return Map.of("kty", "RSA", "use", "sig", "alg", "RS256", "kid", "k1",
"n", Base64Url.encode(unsigned(key.getModulus())), "e", Base64Url.encode(unsigned(key.getPublicExponent())));
}
private static byte[] unsigned(BigInteger number) {
byte[] bytes = number.toByteArray();
return bytes[0] == 0 ? java.util.Arrays.copyOfRange(bytes, 1, bytes.length) : bytes;
}
private void token(HttpExchange exchange) throws IOException {
Map<String, String> form = new HashMap<>();
for (String pair : new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8).split("&")) {
String[] kv = pair.split("=", 2);
form.put(URLDecoder.decode(kv[0], StandardCharsets.UTF_8), URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
}
tokenRequests.add(new TokenRequest(exchange.getRequestHeaders().getFirst("Authorization"), form));
reply(exchange, tokenStatus, Map.of("id_token", idTokenToServe, "token_type", "Bearer"));
}
private static void reply(HttpExchange exchange, int status, Object body) throws IOException {
byte[] bytes = JSON.writeValueAsBytes(body);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
}
@@ -7,6 +7,7 @@ import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.config.PresetFile;
import home.backend.application.GatewayClient;
import home.backend.application.GatewayBattery;
@@ -29,6 +30,7 @@ import home.backend.application.HeatPumpService;
import home.backend.application.HistoryRepository;
import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryService;
import home.backend.application.ProgramService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.application.WeatherService;
@@ -38,6 +40,9 @@ 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.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.Periods;
import home.backend.domain.HistoryView.Point;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -52,6 +57,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
@@ -115,10 +121,10 @@ class ApiServerTest {
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));
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 8.0, 44.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 8.5, 43.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 9.0, 45.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 9.0, 46.0, Periods.UNKNOWN));
};
verdict = Status.APPLIED;
restoreOutcome = Outcome.RESTORED;
@@ -205,6 +211,7 @@ class ApiServerTest {
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
new BatteryService(gateway, batteryCommands, clock),
new AuditService(auditRows(), clock), new StatsService(samples, clock),
new ProgramService((from, to) -> samples.samples(from, to), clock),
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
(from, to) -> exampleMeasuredPv(), clock),
() -> {
@@ -214,7 +221,7 @@ class ApiServerTest {
refreshes++;
return new RefreshResult(List.of("deye", "house-meter"),
List.of(new RefreshResult.Skipped("heat-pump", 41)));
});
}, Authentication.disabled());
server.start();
}
@@ -249,10 +256,10 @@ class ApiServerTest {
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
"pump_outlet_temp", 19.7, "outdoor_avg_temp", 4.8, "target_room_temp", 18.0));
pump.putAll(Map.of("target_co_temp", 31.0, "target_cwu_temp", 8.0, "pump_mode", 0.0,
"output_compressor", 0.0, "output_heater", 0.0, "alarm", 0.0,
"output_compressor", 0.0, "alarm", 0.0,
"setpoint_co_economy", 18.0, "setpoint_co_comfort", 19.0, "setpoint_cwu_economy", 8.0));
pump.putAll(Map.of("setpoint_cwu_comfort", 45.0, "output_co", 0.0, "output_buffer", 0.0,
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0, "program", 1.0));
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
@@ -273,7 +280,7 @@ 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, "total_power_active", 388.78), energyTime))),
new DeviceSnapshot("deye-settings", true, energyTime, null,
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
new DeviceSnapshot("bme280", true, energyTime, null,
@@ -389,13 +396,12 @@ class ApiServerTest {
@Test
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
var response = post("/api/battery/schedule", example("battery-schedule-request").toString(),
"Remote-User", "anna");
var response = post("/api/battery/schedule", example("battery-schedule-request").toString());
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);
new BatteryChange.Slot(3, null, 85, null))) + " by phone"), batterySent);
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
JSON.readTree(response.body()));
}
@@ -528,6 +534,38 @@ class ApiServerTest {
assertEquals(502, get("/api/stats?range=7d").statusCode());
}
@Test
void programMatchesExample() throws Exception {
// Friday 2026-10-23, CEST: CWU comfort from 11:25 (09:25 UTC) to 12:05.
Periods economy = Periods.of(1, new Targets(18.0, 31.0, 8.0), new Setpoints(18, 19, 8, 43));
Periods comfort = Periods.of(1, new Targets(18.0, 31.0, 43.0), new Setpoints(18, 19, 8, 43));
samples = (from, to) -> {
List<HeatPumpSample> polls = new ArrayList<>();
for (int minute = 20; minute <= 125; minute += 5) {
Instant t = Instant.parse("2026-10-23T09:00:00Z").plus(Duration.ofMinutes(minute));
polls.add(new HeatPumpSample(t, null, null, null, null, null,
minute >= 25 && minute <= 60 ? comfort : economy));
}
return polls;
};
assertMatchesExample("/api/program?days=7", "program");
}
@Test
void programNeedsDaysFromSevenToThirty() throws Exception {
assertEquals(200, get("/api/program").statusCode());
assertEquals(400, get("/api/program?days=3").statusCode());
assertEquals(400, get("/api/program?days=x").statusCode());
}
@Test
void programIs502WhenInfluxIsDown() throws Exception {
historyDown = true;
assertEquals(502, get("/api/program").statusCode());
}
@Test
void historyRangeDefaultsTo24Hours() throws Exception {
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
@@ -642,11 +680,11 @@ class ApiServerTest {
@Test
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}");
assertEquals(200, response.statusCode());
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
assertEquals(List.of("cwu_economy by anna"), restored);
assertEquals(List.of("cwu_economy by phone"), restored);
}
@Test
@@ -730,12 +768,10 @@ class ApiServerTest {
}
@Test
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception {
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl");
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
post("/api/setpoints", BOOST);
void aRemoteUserHeaderIsNotTrusted() throws Exception {
post("/api/setpoints", BOOST, "Remote-User", "anna");
assertEquals(List.of("anna.k@example.pl", "phone", "phone"), sent.stream().map(Command::requestedBy).toList());
assertEquals(List.of("phone"), sent.stream().map(Command::requestedBy).toList());
}
@Test
@@ -827,6 +863,25 @@ class ApiServerTest {
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
}
@Test
void timedPresetTakesOwnHoursAndValue() throws Exception {
var response = post("/api/presets/cwu-now", example("preset-cwu-now-request").toString());
assertEquals(200, response.statusCode());
assertEquals(List.of(new Command("cwu_economy", 47, Duration.ofHours(3), "phone")), sent);
}
@Test
void invalidHoursAre400AndSendNothing() throws Exception {
for (String hours : List.of("0", "5", "-1", "2.5", "\"2\"")) {
assertEquals(400, post("/api/presets/cwu-now", "{\"hours\":" + hours + "}").statusCode(), hours);
}
var held = post("/api/presets/away", "{\"until\":\"2026-11-02T18:00:00Z\",\"hours\":2}");
assertEquals(400, held.statusCode());
assertEquals("invalid_hours", JSON.readTree(held.body()).get("error").asText());
assertTrue(sent.isEmpty());
}
@Test
void unknownPresetIs404() throws Exception {
assertEquals(404, post("/api/presets/nope", "").statusCode());
@@ -2,6 +2,8 @@ package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.Period;
import home.backend.domain.Periods;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import org.junit.jupiter.api.AfterEach;
@@ -216,13 +218,16 @@ class InfluxHistoryRepositoryTest {
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"
assertEquals("SELECT output_compressor, output_cwu, output_co, outdoor_temp, cwu_temp,"
+ " program, target_room_temp, target_cwu_temp, setpoint_co_economy, setpoint_co_comfort,"
+ " setpoint_cwu_economy, setpoint_cwu_comfort"
+ " 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),
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 8.5, 43.9,
new Periods(Period.COMFORT, Period.COMFORT, 1, null)), samples.get(1));
assertEquals(Period.ECONOMY, samples.get(0).periods().cwu());
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, null, null, Periods.UNKNOWN),
samples.get(2));
}
@@ -0,0 +1,278 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.Periods;
import home.backend.domain.ProgramView;
import home.backend.domain.ProgramView.Circuit;
import home.backend.domain.ProgramView.Confidence;
import home.backend.domain.ProgramView.Interval;
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.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ProgramServiceTest {
/** Tuesday 2026-10-27 12:00 in Warsaw; the Sunday before it, 10-25, is the day of the change to winter time. */
private static final Instant NOW = Instant.parse("2026-10-27T11:00:00Z");
private static final int POLL_MINUTES = 5;
private Instant askedFrom;
private Instant askedTo;
private ProgramView program(String days, List<HeatPumpSample> samples) throws IOException {
HeatPumpSamples repository = (from, to) -> {
askedFrom = from;
askedTo = to;
return samples;
};
return new ProgramService(repository, Clock.fixed(NOW, ZoneOffset.UTC)).program(days);
}
private static HeatPumpSample poll(String utc, Periods periods) {
return new HeatPumpSample(Instant.parse(utc), null, null, null, null, null, periods);
}
/** CWU economy 8, comfort 43; room economy 18, comfort 19, as on the real controller. */
private static Periods weekly(double cwuTarget, double roomTarget) {
return program(1, cwuTarget, roomTarget, 8, 43);
}
private static Periods program(int program, double cwuTarget, double roomTarget, int cwuEconomy, int cwuComfort) {
return Periods.of(program, new Targets(roomTarget, 31.0, cwuTarget),
new Setpoints(18, 19, cwuEconomy, cwuComfort));
}
private static Periods economy() {
return weekly(8.0, 18.0);
}
private static Periods cwuComfort() {
return weekly(43.0, 18.0);
}
/** Polls every 5 minutes from {@code from} to {@code to} (UTC, "2026-10-19T09:00"), all of them {@code periods}. */
private static List<HeatPumpSample> polls(String from, String to, Periods periods) {
List<HeatPumpSample> samples = new ArrayList<>();
Instant end = Instant.parse(to + ":00Z");
for (Instant t = Instant.parse(from + ":00Z"); !t.isAfter(end); t = t.plus(Duration.ofMinutes(POLL_MINUTES))) {
samples.add(new HeatPumpSample(t, null, null, null, null, null, periods));
}
return samples;
}
@SafeVarargs
private static List<HeatPumpSample> concat(List<HeatPumpSample>... parts) {
return Arrays.stream(parts).flatMap(List::stream).toList();
}
/** "2026-10-19T10:25" for a local time of that date, given the offset from UTC. */
private static String utc(String date, String local, int offsetHours, int plusMinutes) {
int total = Integer.parseInt(local.substring(0, 2)) * 60 + Integer.parseInt(local.substring(3))
- offsetHours * 60 + plusMinutes;
return String.format("%sT%02d:%02d", date, total / 60, total % 60);
}
/** CWU economy, then comfort from {@code from} to {@code to} (local), then economy, polled every 5 minutes. */
private static List<HeatPumpSample> cwuDay(String date, String from, String to, int offsetHours) {
return concat(
polls(utc(date, from, offsetHours, -30), utc(date, from, offsetHours, -5), economy()),
polls(utc(date, from, offsetHours, 0), utc(date, to, offsetHours, -5), cwuComfort()),
polls(utc(date, to, offsetHours, 0), utc(date, to, offsetHours, 30), economy()));
}
private static Circuit monday(ProgramView view) {
return view.weekdays().get(0).cwu();
}
@Test
void asksForWholeLocalDaysBeforeToday() throws IOException {
ProgramView view = program("14", List.of());
assertEquals(14, view.days());
assertEquals(Instant.parse("2026-10-12T22:00:00Z"), askedFrom);
assertEquals(Instant.parse("2026-10-26T23:00:00Z"), askedTo);
}
@Test
void defaultsToFourteenDaysAndListsMondayToSunday() throws IOException {
ProgramView view = program(null, List.of());
assertEquals(14, view.days());
assertEquals(List.of(1, 2, 3, 4, 5, 6, 7), view.weekdays().stream().map(ProgramView.Weekday::day).toList());
}
@Test
void rejectsDaysOutsideSevenToThirty() {
for (String days : new String[] {"6", "31", "abc", ""}) {
assertThrows(IllegalArgumentException.class, () -> program(days, List.of()), days);
}
}
@Test
void noDataGivesNoDataForEveryWeekday() throws IOException {
Circuit circuit = monday(program("14", List.of()));
assertEquals(Confidence.NO_DATA, circuit.confidence());
assertEquals(0, circuit.observedDays());
assertEquals(List.of(), circuit.comfort());
}
@Test
void aBoundaryIsHalfwayBetweenTheTwoPollsRoundedToFiveMinutes() throws IOException {
// Economy at 11:23:48 local (CEST), comfort at 11:28:48: the change was about 11:26, so 11:25.
// Comfort at 12:04:56, economy at 12:09:56: about 12:07, so 12:05.
List<HeatPumpSample> samples = concat(
List.of(poll("2026-10-19T09:23:48Z", economy()), poll("2026-10-19T09:28:48Z", cwuComfort())),
polls("2026-10-19T09:33", "2026-10-19T10:00", cwuComfort()),
List.of(poll("2026-10-19T10:04:56Z", cwuComfort()), poll("2026-10-19T10:09:56Z", economy())));
Circuit circuit = monday(program("14", samples));
assertEquals(List.of(new Interval("11:25", "12:05")), circuit.comfort());
assertEquals(Confidence.SEEN_ONCE, circuit.confidence());
assertEquals(1, circuit.observedDays());
}
@Test
void sameIntervalsOnTwoMondaysWithinFiveMinutesAreConfirmed() throws IOException {
List<HeatPumpSample> samples = concat(
cwuDay("2026-10-19", "11:25", "12:05", 2), cwuDay("2026-10-26", "11:30", "12:05", 1));
Circuit circuit = monday(program("14", samples));
assertEquals(Confidence.CONFIRMED, circuit.confidence());
assertEquals(2, circuit.observedDays());
assertEquals(2, circuit.agreeingDays());
}
@Test
void differentIntervalsOnTwoMondaysAreSeenOnceAndTheLatestIsShown() throws IOException {
List<HeatPumpSample> samples = concat(
cwuDay("2026-10-19", "11:25", "12:05", 2), cwuDay("2026-10-26", "13:00", "14:00", 1));
Circuit circuit = monday(program("14", samples));
assertEquals(Confidence.SEEN_ONCE, circuit.confidence());
assertEquals(2, circuit.observedDays());
assertEquals(1, circuit.agreeingDays());
assertEquals(List.of(new Interval("13:00", "14:00")), circuit.comfort());
}
@Test
void theMajorityPatternWinsOverOneOddDay() throws IOException {
List<HeatPumpSample> samples = concat(
cwuDay("2026-10-12", "11:25", "12:05", 2), cwuDay("2026-10-19", "11:25", "12:05", 2),
cwuDay("2026-10-26", "13:00", "14:00", 1));
Circuit circuit = monday(program("30", samples));
assertEquals(Confidence.CONFIRMED, circuit.confidence());
assertEquals(List.of(new Interval("11:25", "12:05")), circuit.comfort());
}
@Test
void aDayWithTheChangeToWinterTimeKeepsLocalClockTimes() throws IOException {
// Sunday 2026-10-18 is CEST (UTC+2), Sunday 2026-10-25 CET (UTC+1): the same local 11:25-12:05.
List<HeatPumpSample> samples = concat(
cwuDay("2026-10-18", "11:25", "12:05", 2), cwuDay("2026-10-25", "11:25", "12:05", 1));
Circuit sunday = program("14", samples).weekdays().get(6).cwu();
assertEquals(Confidence.CONFIRMED, sunday.confidence());
assertEquals(List.of(new Interval("11:25", "12:05")), sunday.comfort());
}
@Test
void anIntervalAcrossMidnightIsSplitOverBothDays() throws IOException {
// Comfort from 22:55 local Sunday to 01:05 local Monday (CET: UTC+1).
List<HeatPumpSample> samples = concat(
polls("2026-11-01T21:50", "2026-11-01T21:50", economy()),
polls("2026-11-01T21:55", "2026-11-02T00:00", cwuComfort()),
polls("2026-11-02T00:05", "2026-11-02T00:10", economy()));
Clock later = Clock.fixed(Instant.parse("2026-11-04T11:00:00Z"), ZoneOffset.UTC);
ProgramView view = new ProgramService((from, to) -> samples, later).program("14");
assertEquals(List.of(new Interval("22:55", "24:00")), view.weekdays().get(6).cwu().comfort());
assertEquals(List.of(new Interval("00:00", "01:05")), view.weekdays().get(0).cwu().comfort());
}
@Test
void ourOwnWritesLeaveNoComfortIntervalButTheDayStaysObserved() throws IOException {
// Economy raised 8 -> 45 -> 43 (equal to comfort, so the periods are ambiguous) -> 8, as in the
// 2026-10-24 tests, all during the economy period.
List<HeatPumpSample> samples = List.of(
poll("2026-10-19T11:00:00Z", program(1, 8.0, 18.0, 8, 43)),
poll("2026-10-19T11:05:00Z", program(1, 45.0, 18.0, 45, 43)),
poll("2026-10-19T11:10:00Z", program(1, 43.0, 18.0, 43, 43)),
poll("2026-10-19T11:15:00Z", program(1, 43.0, 18.0, 43, 43)),
poll("2026-10-19T11:20:00Z", program(1, 8.0, 18.0, 8, 43)));
Circuit circuit = monday(program("14", samples));
assertEquals(List.of(), circuit.comfort());
assertEquals(1, circuit.observedDays());
}
@Test
void aWriteInsideComfortDropsTheIntervalInsteadOfGuessingItsEnd() throws IOException {
// Economy written 8 -> 43 inside the comfort period makes the CWU periods ambiguous for two polls.
List<HeatPumpSample> samples = concat(
polls("2026-10-19T09:55", "2026-10-19T09:55", economy()),
polls("2026-10-19T10:00", "2026-10-19T10:05", cwuComfort()),
polls("2026-10-19T10:10", "2026-10-19T10:15", program(1, 43.0, 18.0, 43, 43)),
polls("2026-10-19T10:20", "2026-10-19T10:55", cwuComfort()),
polls("2026-10-19T11:00", "2026-10-19T11:05", economy()));
Circuit circuit = monday(program("14", samples));
assertEquals(List.of(), circuit.comfort());
assertEquals(1, circuit.observedDays());
}
@Test
void anotherProgramBreaksTheChain() throws IOException {
List<HeatPumpSample> samples = List.of(
poll("2026-10-19T09:55:00Z", economy()), poll("2026-10-19T10:00:00Z", cwuComfort()),
poll("2026-10-19T10:05:00Z", program(2, 43.0, 18.0, 8, 43)),
poll("2026-10-19T10:10:00Z", economy()));
Circuit circuit = monday(program("14", samples));
assertEquals(List.of(), circuit.comfort());
}
@Test
void aGapInTheDataBreaksTheChain() throws IOException {
List<HeatPumpSample> samples = List.of(
poll("2026-10-19T09:55:00Z", economy()), poll("2026-10-19T10:00:00Z", cwuComfort()),
poll("2026-10-19T10:40:00Z", economy()));
assertEquals(List.of(), monday(program("14", samples)).comfort());
}
@Test
void roomIsReadFromItsOwnTargets() throws IOException {
List<HeatPumpSample> samples = concat(
polls("2026-10-19T09:55", "2026-10-19T09:55", weekly(8.0, 18.0)),
polls("2026-10-19T10:00", "2026-10-19T10:55", weekly(8.0, 19.0)),
polls("2026-10-19T11:00", "2026-10-19T11:00", weekly(8.0, 18.0)));
ProgramView view = program("14", samples);
assertEquals(List.of(), monday(view).comfort());
assertEquals(List.of(new Interval("12:00", "13:00")), view.weekdays().get(0).room().comfort());
}
}
@@ -16,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.Period;
import home.backend.domain.PiSensorView;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
@@ -71,14 +72,39 @@ class ServicesTest {
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
}
@Test
void heatPumpPeriodsComeFromTargetsAndSetpoints() throws IOException {
HeatPumpView view = heatPump(Map.of("program", 1.0, "target_cwu_temp", 43.0, "setpoint_cwu_economy", 8.0,
"setpoint_cwu_comfort", 43.0, "target_room_temp", 18.0, "setpoint_co_economy", 18.0,
"setpoint_co_comfort", 19.0));
assertEquals(Period.COMFORT, view.periods().cwu());
assertEquals(Period.ECONOMY, view.periods().room());
assertNull(view.periods().programText());
}
@Test
void heatPumpOtherProgramOverridesBothPeriodsAndIsNamed() throws IOException {
HeatPumpView view = heatPump(Map.of("program", 2.0, "target_cwu_temp", 43.0, "setpoint_cwu_economy", 8.0,
"setpoint_cwu_comfort", 43.0));
assertEquals(Period.OVERRIDE, view.periods().cwu());
assertEquals(Period.OVERRIDE, view.periods().room());
assertEquals("Party", view.periods().programText());
}
@Test
void heatPumpWithoutProgramHasUnknownPeriods() throws IOException {
assertEquals(Period.UNKNOWN, heatPump(Map.of("target_cwu_temp", 43.0)).periods().cwu());
}
@Test
void heatPumpMapsOutputsAlarmAndTime() throws IOException {
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0, "output_heater", 100.0,
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0,
"output_cwu", 100.0, "output_co", 0.0, "alarm", 4.0, "setpoint_cwu_economy", 8.0));
assertEquals(READ, view.time());
assertEquals(66, view.compressorPercent());
assertTrue(view.heaterOn());
assertTrue(view.alarm());
assertTrue(view.pumps().cwu());
assertFalse(view.pumps().co());
@@ -402,7 +428,8 @@ class ServicesTest {
assertEquals(2000.0, view.pvPowerW());
assertEquals(4.4, view.today().pvKwh());
assertNull(view.gridPowerW());
assertNull(view.today().loadKwh());
assertNull(view.housePowerW());
assertNull(view.today().houseKwh());
}
@Test
@@ -415,6 +442,82 @@ class ServicesTest {
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
}
private static Map<String, Double> deye(double pv1, double pv2, double battery, double grid) {
return Map.of("pv1_power_w", pv1, "pv2_power_w", pv2, "battery_power_w", battery, "grid_power_w", grid,
"load_power_w", 0.0);
}
private static EnergyView house(Map<String, Double> deye, double froniusW, double meterW) throws IOException {
DeviceSnapshot meter = device("house-meter", "electricity", "PRAD2",
Map.of("total_power_active", meterW));
return energy(device("deye", "home", "deye", deye),
device("fronius", "home", "fronius", Map.of("pv_power_w", froniusW)), meter);
}
@Test
void housePowerBalancesPvBatteryAndMeterWhileExporting() throws IOException {
EnergyView view = house(deye(2638.0, 3000.0, -15.0, -5768.0), 444.0, -5762.0);
assertEquals(305.0, view.housePowerW());
assertEquals(0.0, view.backupOutputW());
}
@Test
void housePowerAtNightIsImportPlusBatteryDischarge() throws IOException {
EnergyView view = house(deye(0.0, 0.0, 800.0, 1000.0), 0.0, 1000.0);
assertEquals(1800.0, view.housePowerW());
}
@Test
void housePowerIsNeverNegative() throws IOException {
assertEquals(0.0, house(deye(0.0, 0.0, -500.0, 100.0), 0.0, 100.0).housePowerW());
}
@Test
void housePowerFallsBackToDeyeGridWhenTheMeterIsStale() throws IOException {
Instant old = NOW.minusSeconds(3600);
var meter = new DeviceSnapshot("house-meter", true, old, null, List.of(new Measurement("electricity",
"PRAD2", Map.of("total_power_active", 9999.0), old)));
EnergyView view = energy(device("deye", "home", "deye", deye(1000.0, 0.0, 0.0, -600.0)),
device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0)), meter);
assertEquals(600.0, view.housePowerW());
}
@Test
void housePowerIsNullWhenASourceIsMissing() throws IOException {
Map<String, Double> deye = deye(1000.0, 0.0, 0.0, -600.0);
assertNull(energy(device("deye", "home", "deye", deye)).housePowerW());
assertNull(energy(device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0))).housePowerW());
var noGrid = Map.of("pv1_power_w", 1.0, "pv2_power_w", 1.0, "battery_power_w", 1.0);
assertNull(energy(device("deye", "home", "deye", noGrid),
device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0))).housePowerW());
}
@Test
void houseTodayIsPvPlusNetGridPlusNetBattery() throws IOException {
var deye = Map.of("pv_today_kwh", 29.9, "grid_bought_today_kwh", 4.4, "grid_sold_today_kwh", 16.8,
"battery_discharge_today_kwh", 5.0, "battery_charge_today_kwh", 2.1);
EnergyView view = energy(device("deye", "home", "deye", deye),
device("fronius", "home", "fronius", Map.of("energy_day_wh", 4400.0)));
assertEquals(24.8, view.today().houseKwh());
}
@Test
void houseTodayIsNullWhenACounterIsMissing() throws IOException {
var deye = Map.of("pv_today_kwh", 29.9, "grid_bought_today_kwh", 4.4, "grid_sold_today_kwh", 16.8,
"battery_discharge_today_kwh", 5.0);
EnergyView view = energy(device("deye", "home", "deye", deye),
device("fronius", "home", "fronius", Map.of("energy_day_wh", 4400.0)));
assertNull(view.today().houseKwh());
}
private static EnergyDetails details(DeviceSnapshot... devices) throws IOException {
return new EnergyDetailsService(gateway(devices), CLOCK).current();
}
@@ -506,8 +609,9 @@ class ServicesTest {
assertNull(view.gridPowerW());
assertNull(view.batteryPowerW());
assertNull(view.batterySocPct());
assertNull(view.loadPowerW());
assertNull(view.today().loadKwh());
assertNull(view.housePowerW());
assertNull(view.backupOutputW());
assertNull(view.today().houseKwh());
}
@Test
@@ -127,15 +127,38 @@ class SetpointServiceTest {
@Test
void boostPresetSendsOneTimedCommand() throws Exception {
var result = service.applyPreset("cwu-now", null, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("cwu-now", null, 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 chosenHoursReplaceTheTimedPresetsOwn() throws Exception {
service.applyPreset("cwu-now", null, 4, Map.of("cwu_economy", 47), "anna");
assertEquals(List.of(new Command("cwu_economy", 47, Duration.ofHours(4), "anna")), sent);
}
@Test
void hoursOutsideOneToFourAreRefusedBeforeAnythingIsSent() {
for (int hours : List.of(0, 5, -1, 72)) {
assertThrows(SetpointService.InvalidHoursException.class,
() -> service.applyPreset("cwu-now", null, hours, Map.of(), "anna"));
}
assertTrue(sent.isEmpty());
}
@Test
void aHeldPresetHasNoHoursToChoose() {
assertThrows(SetpointService.InvalidHoursException.class,
() -> service.applyPreset("away", RETURN, 2, Map.of(), "anna"));
assertTrue(sent.isEmpty());
}
@Test
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(2, result.results().size());
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
@@ -144,7 +167,7 @@ class SetpointServiceTest {
@Test
void ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
service.applyPreset("away", RETURN, Map.of("co_comfort", 16), "anna");
service.applyPreset("away", RETURN, null, 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);
@@ -152,7 +175,7 @@ class SetpointServiceTest {
@Test
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
service.applyPreset("cwu-now", null, Map.of("cwu_economy", 40), "anna");
service.applyPreset("cwu-now", null, null, Map.of("cwu_economy", 40), "anna");
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
}
@@ -160,7 +183,7 @@ class SetpointServiceTest {
@Test
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
assertThrows(IllegalArgumentException.class,
() -> service.applyPreset("away", RETURN, Map.of("cwu_economy", 6), "anna"));
() -> service.applyPreset("away", RETURN, null, Map.of("cwu_economy", 6), "anna"));
assertTrue(sent.isEmpty());
}
@@ -168,14 +191,14 @@ class SetpointServiceTest {
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
for (int value : List.of(4, 26, 0)) {
assertThrows(IllegalArgumentException.class,
() -> service.applyPreset("away", RETURN, Map.of("co_comfort", value), "anna"));
() -> service.applyPreset("away", RETURN, null, 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();
var result = service.applyPreset("away", RETURN, null, Map.of("co_comfort", 19), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
@@ -184,8 +207,8 @@ class SetpointServiceTest {
@Test
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, Map.of(), "anna"));
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, Map.of(), "anna"));
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, null, Map.of(), "anna"));
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, null, Map.of(), "anna"));
assertTrue(sent.isEmpty());
}
@@ -194,7 +217,7 @@ class SetpointServiceTest {
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();
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
@@ -204,7 +227,7 @@ class SetpointServiceTest {
@Test
void unknownPresetIsEmpty() throws Exception {
assertTrue(service.applyPreset("nope", null, Map.of(), "anna").isEmpty());
assertTrue(service.applyPreset("nope", null, null, Map.of(), "anna").isEmpty());
assertTrue(sent.isEmpty());
}
@@ -213,7 +236,7 @@ class SetpointServiceTest {
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", null, Map.of(), "anna").orElseThrow();
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
@@ -225,7 +248,7 @@ class SetpointServiceTest {
void presetWithUnknownSetpointWritesNothing() throws Exception {
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", null, Map.of(), "anna").orElseThrow();
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertTrue(sent.isEmpty());
@@ -239,7 +262,7 @@ class SetpointServiceTest {
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
script.add(Status.FAILED);
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -250,7 +273,7 @@ class SetpointServiceTest {
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
@@ -260,7 +283,7 @@ class SetpointServiceTest {
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
script.add(Status.REJECTED);
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -270,7 +293,7 @@ class SetpointServiceTest {
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
@@ -280,7 +303,7 @@ class SetpointServiceTest {
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
script.add(new IOException("tunnel dropped"));
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -292,7 +315,7 @@ class SetpointServiceTest {
assertThrows(IOException.class, service::current);
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, Map.of(), "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, null, Map.of(), "anna"));
assertTrue(sent.isEmpty());
}
}
@@ -1,6 +1,7 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.Periods;
import home.backend.domain.StatsView;
import home.backend.domain.StatsView.Day;
import org.junit.jupiter.api.Test;
@@ -35,7 +36,7 @@ class StatsServiceTest {
}
private static HeatPumpSample compressor(String time, double stage) {
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, 0.0, null, null);
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, null, null, Periods.UNKNOWN);
}
private static Day day(StatsView view, String date) {
@@ -158,15 +159,14 @@ class StatsServiceTest {
@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));
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 100.0, 0.0, 10.0, 40.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 12.0, 38.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, null, null, Periods.UNKNOWN));
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());
@@ -175,13 +175,12 @@ class StatsServiceTest {
@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));
new HeatPumpSample(Instant.parse("2026-10-22T10:00:00Z"), 100.0, 0.0, 0.0, 10.0, 30.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 10.0, 31.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 4.0, 45.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 4.0, 44.0, Periods.UNKNOWN));
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());
@@ -190,7 +189,7 @@ class StatsServiceTest {
@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),
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), null, null, null, null, null, Periods.UNKNOWN),
compressor("2026-10-23T10:05:00Z", 100));
assertEquals(0, day(view, "2026-10-23").compressorStarts());
@@ -0,0 +1,78 @@
package home.backend.domain;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.Targets;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class PeriodsTest {
/** CWU economy 8 and comfort 43, room economy 18 and comfort 19. */
private static Periods of(Integer program, double cwuTarget, double roomTarget) {
return Periods.of(program, new Targets(roomTarget, 31.0, cwuTarget), new Setpoints(18, 19, 8, 43));
}
@Test
void targetEqualToAComfortSetpointIsComfort() {
Periods periods = of(1, 43.0, 19.0);
assertEquals(Period.COMFORT, periods.cwu());
assertEquals(Period.COMFORT, periods.room());
}
@Test
void targetEqualToAnEconomySetpointIsEconomy() {
Periods periods = of(1, 8.0, 18.0);
assertEquals(Period.ECONOMY, periods.cwu());
assertEquals(Period.ECONOMY, periods.room());
}
@Test
void targetBetweenTheSetpointsIsUnknown() {
assertEquals(Period.UNKNOWN, of(1, 25.0, 18.5).cwu());
assertEquals(Period.UNKNOWN, of(1, 25.0, 18.5).room());
}
@Test
void equalSetpointsAreUnknownBecauseTheTargetIsTheSameEitherWay() {
Periods periods = Periods.of(1, new Targets(18.0, 31.0, 43.0), new Setpoints(18, 19, 43, 43));
assertEquals(Period.UNKNOWN, periods.cwu());
assertEquals(Period.ECONOMY, periods.room());
}
@Test
void aProgramOtherThanWeeklyOverridesAndIsNamed() {
String[] names = {"Ferie", null, "Party", "Rozruch", "Stop", "Dezynfekcja"};
for (int program = 0; program < names.length; program++) {
if (program != 1) {
Periods periods = of(program, 43.0, 19.0);
assertEquals(Period.OVERRIDE, periods.cwu());
assertEquals(Period.OVERRIDE, periods.room());
assertEquals(names[program], periods.programText());
}
}
assertEquals("Program 9", of(9, 8.0, 18.0).programText());
}
@Test
void weeklyProgramHasNoText() {
assertNull(of(1, 8.0, 18.0).programText());
}
@Test
void missingValuesAreUnknown() {
assertEquals(Periods.UNKNOWN, Periods.of(null, new Targets(null, null, null), new Setpoints(null, null, null, null)));
assertEquals(Period.UNKNOWN, Periods.of(1, new Targets(18.0, null, null), new Setpoints(18, 19, 8, 43)).cwu());
assertEquals(Period.UNKNOWN, Periods.of(1, new Targets(18.0, null, 8.0), new Setpoints(null, null, 8, null)).cwu());
}
@Test
void periodSerialisesAsLowerCaseId() {
assertEquals("comfort", Period.COMFORT.id());
assertEquals("override", Period.OVERRIDE.id());
}
}
@@ -0,0 +1 @@
{ "local": true, "oidc": true, "setup": false }
@@ -0,0 +1 @@
{ "user": "tata", "method": "local" }
@@ -13,7 +13,7 @@
{ "title": "Sieć", "items": [
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
] },
{ "title": "Odbiór domu", "items": [
{ "title": "Wyjście zapasowe falownika", "items": [
{ "label": "Moc razem", "value": 570.0, "unit": "W", "digits": 0 }
] },
{ "title": "Panele PV", "items": [
@@ -22,7 +22,7 @@
] },
{ "title": "Dziś", "items": [
{ "label": "Produkcja PV", "value": 29.9, "unit": "kWh", "digits": 1 },
{ "label": "Zużycie domu", "value": 24.2, "unit": "kWh", "digits": 1 },
{ "label": "Wyjście zapasowe", "value": 24.2, "unit": "kWh", "digits": 1 },
{ "label": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 },
{ "label": "Bateria naładowana", "value": 0.5, "unit": "kWh", "digits": 1 },
@@ -49,6 +49,7 @@
"id": "house-meter", "name": "Licznik domu", "time": "2026-10-23T22:26:00Z", "stale": false,
"groups": [
{ "title": "Moc czynna", "items": [
{ "label": "Razem", "value": 389.0, "unit": "W", "digits": 0 },
{ "label": "Moc L1", "value": 56.0, "unit": "W", "digits": 0 }
] },
{ "title": "Napięcie", "items": [
@@ -8,6 +8,7 @@
"gridPowerW": null,
"batteryPowerW": null,
"batterySocPct": null,
"loadPowerW": null,
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "loadKwh": null }
"housePowerW": null,
"backupOutputW": null,
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "houseKwh": null }
}
+3 -2
View File
@@ -8,6 +8,7 @@
"gridPowerW": 645.0,
"batteryPowerW": -24.0,
"batterySocPct": 99.0,
"loadPowerW": 570.0,
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "loadKwh": 24.2 }
"housePowerW": 364.78,
"backupOutputW": 570.0,
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "houseKwh": 28.4 }
}
@@ -3,7 +3,6 @@
"stale": true,
"state": null,
"compressorPercent": null,
"heaterOn": false,
"alarm": false,
"alarmCode": null,
"alarmText": null,
@@ -13,5 +12,6 @@
"groundIn": null, "groundOut": null, "pumpOutlet": null, "outdoorAvg": null
},
"targets": { "room": null, "co": null, "cwu": null },
"setpoints": { "coEconomy": null, "coComfort": null, "cwuEconomy": null, "cwuComfort": null }
"setpoints": { "coEconomy": null, "coComfort": null, "cwuEconomy": null, "cwuComfort": null },
"periods": { "cwu": "unknown", "room": "unknown", "program": null, "programText": null }
}
@@ -3,7 +3,6 @@
"stale": false,
"state": "IDLE",
"compressorPercent": 0,
"heaterOn": false,
"alarm": false,
"alarmCode": null,
"alarmText": null,
@@ -13,5 +12,6 @@
"groundIn": 16.0, "groundOut": 14.3, "pumpOutlet": 19.7, "outdoorAvg": 4.8
},
"targets": { "room": 18.0, "co": 31.0, "cwu": 8.0 },
"setpoints": { "coEconomy": 18, "coComfort": 19, "cwuEconomy": 8, "cwuComfort": 45 }
"setpoints": { "coEconomy": 18, "coComfort": 19, "cwuEconomy": 8, "cwuComfort": 45 },
"periods": { "cwu": "economy", "room": "economy", "program": 1, "programText": null }
}
@@ -0,0 +1 @@
{ "hours": 3, "values": { "cwu_economy": 47 } }
@@ -0,0 +1,20 @@
{
"days": 7,
"weekdays": [
{ "day": 1, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
{ "day": 2, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
{ "day": 3, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
{ "day": 4, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
{ "day": 5, "cwu": { "confidence": "seen_once", "observedDays": 1, "agreeingDays": 1,
"comfort": [ { "from": "11:25", "to": "12:05" } ] },
"room": { "confidence": "seen_once", "observedDays": 1, "agreeingDays": 1, "comfort": [] } },
{ "day": 6, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
{ "day": 7, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } }
]
}
@@ -7,7 +7,6 @@
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
@@ -18,7 +17,6 @@
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
@@ -29,7 +27,6 @@
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
@@ -40,7 +37,6 @@
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
@@ -51,7 +47,6 @@
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
@@ -62,7 +57,6 @@
"compressorStarts": 1,
"cwuHours": 0.08,
"coHours": 0.0,
"heaterHours": 0.08,
"avgOutdoorTemp": 8.6,
"minCwuTemp": 43.0,
"maxCwuTemp": 46.0
@@ -73,7 +67,6 @@
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
@@ -84,7 +77,6 @@
"compressorStarts": 1,
"cwuHours": 0.08,
"coHours": 0.0,
"heaterHours": 0.08,
"avgOutdoorTemp": 8.6,
"minCwuTemp": 43.0,
"maxCwuTemp": 46.0
@@ -1 +1 @@
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":0.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,Line truncated
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":1.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,Line truncated
@@ -1 +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]]}]}]}
{"results":[{"statement_id":0,"series":[{"name":"heat_pump","columns":["time","output_compressor","output_cwu","output_co","outdoor_temp","cwu_temp","program","target_room_temp","target_cwu_temp","setpoint_co_economy","setpoint_co_comfort","setpoint_cwu_economy","setpoint_cwu_comfort"],"values":[[1792771200000,0,0,0,8.5,44.2,1,18.0,8.0,18,19,8,43],[1792771500000,100,100,0,8.5,43.9,1,19.0,43.0,18,19,8,43],[1792771800000,50,0,100,null,null,null,null,null,null,null,null,null]]}]}]}
@@ -1,6 +1,6 @@
# ADR-009: Web UI reachable from Krosno only, without a login for now
Status: accepted · 2026-10-24
Status: superseded by ADR-016 · 2026-10-24
## Context
+33
View File
@@ -0,0 +1,33 @@
# ADR-014: Sign-in in the app: local accounts and Keycloak, independent
Status: accepted · 2026-10-24
## Context
ADR-009 left the UI without a login, admitted by Traefik's address whitelist. The app is to be
used away from home, and writes to the heat pump and the battery are on. Keycloak runs in Kraków
but may be down, and then my parents must still get in.
## Decision
- The backend authenticates itself; it no longer trusts `Remote-User` (the header handling and
the Traefik middleware that cleared it are dropped). The whitelist of ADR-009 stays in front for now.
- Two independent ways in, either or both configured:
- Local accounts: `USERS_FILE`, PBKDF2-SHA256 hashes (at least 310000 iterations, 600000 by
default), managed in the app by any signed-in user; the backend rewrites the file atomically. Five failures in 15 minutes block the username and
the client address.
- OIDC with Keycloak: authorization code with PKCE, state and nonce, a confidential client, the
ID token verified in the backend with the JDK. Discovery and keys are fetched lazily, so
Keycloak being down never stops the start or local login.
- With `USERS_FILE` set but no account in it the app is in setup mode: the first visitor creates the
first account. Accepted because Traefik admits only the two home networks.
- A stateless session cookie signed with `SESSION_SECRET` (30 days, HttpOnly, Secure, SameSite=Lax).
- No silent open mode: without either configured the backend refuses to start unless
`AUTH_DISABLED=true`. `/api/live` and `/api/auth/*` are the only open paths.
## Consequences
- Supersedes the "no login" part of ADR-009; its network rules and "no published port" stay.
- Sessions cannot be revoked one by one; changing `SESSION_SECRET` signs everyone out.
- Auditing names the real user, whichever way they came in.
- Operating it needs a Keycloak client and a users file next to the stack, outside the image.
@@ -0,0 +1,28 @@
# ADR-016: Web UI reachable from the internet behind the sign-in
Status: accepted · 2026-10-24 · supersedes ADR-009
## Context
ADR-009 kept the UI on the Krosno network without a login. ADR-014 added a sign-in (local
accounts, the first one created in the app, and Keycloak realm `home`). My father should see
and change the house from anywhere, e.g. while away (ADR-012).
## Decision
- Traefik no longer filters by address for the app: every `/api/` call needs a session (ADR-014),
`/api/live` and the static UI excepted.
- Only `/api/auth/setup` keeps an allowlist (the Krosno ISP pools, the WireGuard range and the
Kraków LAN), on its own router with a higher priority. Setup is open only while no account
exists, so a lost users file must not hand the house to the first visitor.
- Keycloak realm `home` has self-registration off; only accounts added by hand can sign in, so
no role is required (the backend logs that every realm user may sign in).
## Consequences
- Path variants of `/api/auth/setup` (encoded, doubled or dotted segments, suffixes) were probed
from outside the allowlist on 2026-10-24: Traefik answers 403 or the backend 404; none reaches
setup.
- Login throttling (5 failures per user and per address in 15 min) now faces the internet; anyone
who knows a username can lock it for 15 minutes. Keycloak stays as the other way in.
- The routers live in homelab-compose (`home-pump/compose.yaml`), not in this repository.
-5
View File
@@ -44,10 +44,6 @@ below therefore come from the PDF, the XML and the MR208 manual, not from code.
4226 is the effective state; 4140 is the chosen program (4226 can say STOP while 4140 says weekly).
Open: the gateway test fixture (real output, PR #40) shows 4226 = 0 while the settings backup has
4140 = 1 (weekly) and rx910-se21.md observed the weekly program. If 4226 really reads 0 then, the
PDF numbering may be shifted by one. Check by reading 4226 and 4140 together, then changing nothing.
## Modes and programs
| Reg | Values | Source |
@@ -115,6 +111,5 @@ The vendor documents no limit. The app's 200 ms pause is the only known pacing;
- What are 4222–4225, and the missing values of 4228? (a read-only log over a heating cycle)
- Does 4011 exist and move on this firmware? Its relation to 4221.
- Is 4226 numbered as the PDF says (see above)?
- What does 4119 read when it is implemented, and is the register used at all?
- Meaning of 4122 value 2 ("*") and of 4126 `Uklad` (no values given).
+105
View File
@@ -0,0 +1,105 @@
# Frisko RX910-SE21: what history shows (2026-10)
Sources, all read-only: `Dom_Pompa.heat_pump` (2026-10-24, 167 samples, 5 min), legacy `POMPA`
(8 temperatures, 5 min, Feb-Oct 2026), `electricity.PRAD2` (house meter, 1 min), `setpoint_audit`.
Times are Europe/Warsaw; 2026-10-24 was CEST (DST ended 2026-10-25 03:00). Sampling gives +-5 min
unless a meter step is quoted (+-1 min). Only one day of `heat_pump` exists so far.
## Period detection (economy or comfort)
Verified from history, 2026-10-24, 167 samples. Rule: compare the target with the two setpoints.
| Circuit | Target / economy / comfort | Result |
|---|---|---|
| CWU | 4203 vs 4143 / 4144 (8 / 43) | 133 economy, 27 comfort, 0 neither outside our writes |
| Room | 4201 vs 4131 / 4132 (18 / 19) | 130 economy, 30 comfort, 0 neither |
- During our 4143 writes (13:20:19 to 13:53:33: 45, 47, 43, 8) the CWU rule is wrong: target = the
economy value we wrote; once economy = 43 = comfort (13:29:47 on) it is ambiguous (5 samples).
Exclude samples within a few minutes after a `setpoint_audit` write, or report "unknown".
- Ambiguity only occurs when two setpoints are equal; the target is then the same either way.
- "Neither" never happened (no Party, Ferie, disinfection: 4226 = 1 on every sample).
- 4226 = 1 all day with the weekly program active: the PDF numbering (1 = Tygodn) holds.
- Target changes at the period boundary within one poll; the first sample after it already shows it.
## Weekly program (hypothesis from one observed day plus 7 weeks of cycles)
2026-10-24 (Saturday), from `heat_pump`: CWU target 43 in 11:25-~12:05 and 17:55-~19:10, else 8.
| Circuit | Comfort start | Comfort end | Evidence |
|---|---|---|---|
| CWU | 11:25 (11:23:48..11:28:48) | ~12:05 (12:04:56..12:09:56) | 4203; meter step +1.4 kW 11:24:55 |
| CWU | 17:55 (17:53:23..17:58:23) | ~19:10 (19:08:50..19:13:50) | 4203; meter step +1.7 kW 17:55:23 |
| Room | ~12:07 (12:04:56..12:09:56) | ~14:55 (14:52:53..14:57:53) | 4201 = 19 vs 18 |
- Legacy `POMPA`, 2026-09-10..10-24: compressor cycles begin at 11:25 and 17:55 on all seven weekdays
(43 morning, 33 evening cycles; the evening one is missing only when the tank is already warm).
Strong evidence that CWU comfort is the same every day. Not read from the controller (4169-4176).
- Unknown: room intervals on other days (CO never ran on 10-24, `output_co` = 0 all day); a possible
third interval; Party/Ferie use. Reading 4167-4177 needs the shared selector.
- The 5-minute "11:30" and "18:00" in older months are a sampling-phase artefact (first poll after start).
## The controller clock does not follow DST (verified from history)
- Cycles began at 11:30 / 18:00 until 2026-03-28; from 03-29 (DST start) at 12:30 / 19:00 local, i.e.
one hour late, until 2026-04-06; from 04-07 back at 11:30 / 18:00. So the clock stayed on CET after
the change and was set by hand on about 04-06.
- Prediction (hypothesis, testable on 2026-10-26): the controller still runs CEST after 10-25 03:00,
so comfort periods start one hour early in local time (about 10:25 and 16:55) until the clock is set.
The app should not assume program times are local civil time.
## Heater signal (output_heater 4115, zb_state 4228)
The electric CWU heater is NOT physically connected (owner, 2026-10-25). Output 4115 = 100 and
`zb_state` 2 only mean the controller's heater signal after 20 min of a CWU cycle; nothing is heated
by it. Below, "heater" means that signal. The meter step at +20 min is the compressor's second stage
(verified 2026-10-25): on 10-24 at 18:14:13 stage 4114 read 33, at 18:19:13 66; the meter rose at
18:15 by ~940 W, split evenly over the phases (+330 / +300 / +310 W), a three-phase load, not a
single-phase heater. The controller raises the stage and the heater signal together.
Episodes in `heat_pump` (10-24); meter steps from `electricity.PRAD2` `total_power_active`.
| Episode | On (sample window) | Off | Preceding CWU run | State at start |
|---|---|---|---|---|
| 1 | 11:43:48..11:48:48 | 12:04:56..12:09:56 (period end) | compressor since 11:24:55, ~20-24 min | stage 33 → 66, cwu 28 / target 43, outlet 39, outdoor 11 |
| 2 | 18:14:13..18:19:13 (meter 18:15:13, +937 W) | ~18:20 (meter -3.7 kW) | compressor since 17:55:23, 19.8 min | stage 33 → 66, cwu 40.6 / 43, outlet 46 then 51.7 |
Verified from history:
- The heater joins 20.0 min (+-0.1) after the compressor starts a CWU cycle, if the cycle is still on.
Meter: nine evenings (10-09, 10, 15, 16, 17, 19, 22, 23, 24) show a step of 665-961 W (mean ~890 W,
about 0.3 kW per phase) at +20.0 min. The five evenings whose cycle ended after 12-15 min (10-02, 03,
04, 06, 21) show none. Mornings 10-06 and 10-23 also show +640 and +756 W at +20 min (PV noise
makes mornings unreliable). Matches 4147 = 20.
- It is not triggered by the outlet limit: at heater start the outlet was 39 (morning) and 46 (evening),
below TmaxPc 52. Not by CO (`output_co` 0), disinfection (4226 = 1), alarm (0), pressure switch (0)
or outdoor temperature (4.6-11.9 C). The cwu temperature differs by 12 C between the two starts.
- `zb_state` = 2 in all 6 samples with `output_heater` = 100 and 0 otherwise: it is the CWU heater ("zbCW").
- The heater ends with the compressor: at the period end (episode 1) or at the TmaxPc stop (episode 2:
outlet 51.7, compressor and heater off, outlet 42.1 five minutes later, cwu 42.2 below target).
The compressor restarts ~18:25:30 (stage 33) without the heater (not 20 min) and stops at 18:30 when
cwu reaches 43.0. Evening heater runs last 1-10 min (meter), ending at the outlet limit.
- The 0.67-0.96 kW step is stage 2 (66 %) over stage 1 (33 %). Over the house base (~1.0 kW on 10-24
evening) the pump draws +1.9..2.5 kW at stage 1 (rising as the outlet warms) and +3.4..3.7 kW at
stage 2. It lasts until the compressor stops.
- At an outlet of 52 C (TmaxPc 4158) the controller stops the compressor because the outlet is too hot
(owner, 2026-10-25; seen 18:19-18:20 at 51.7 C); it restarts at stage 1 once the outlet has cooled.
Hypothesis: the signal delay is measured from the compressor start of a CWU request, not from a
temperature condition. A cycle that finishes within 20 min never raises it.
## CWU heating cycles (legacy POMPA, 2026-09-10..10-24, outlet and cwu rise)
- Start: 11:25 (43 cycles) and 17:55 (33 cycles), meter-confirmed: they begin with the comfort period.
- Duration, 5-min resolution: morning median 35 min (15-50), evening median 25 min (15-50).
- Start cwu: morning 13-36 C (median 26), evening 18-44 C (median 36). Cold starts run 40-50 min.
- End cwu: 43-44.2 C when the target is reached (overshoot <= 1.2 C), observed 25 evening and 21
morning times. Other morning cycles end at 38.7-42 C because comfort ends at ~12:05.
- Heating continues to the target, not for a fixed time. The sensor keeps rising 0.5-0.7 C for 5-10 min
after the compressor stops (stratification): do not read the end of a cycle from cwu alone.
- `output_cwu` stays 100 for 5-10 min after the compressor stops; `pump_mode` can read 0 while the
compressor runs (10-24 13:36, 18:24, 18:29). Do not use 4227 alone to show "heating CWU".
- Start condition is unclear: cycles began at cwu 40.8-42.8 C (10-02, 03, 04, 06) and a test at 41.4 vs
target 45 started within 5 min, yet 10-19/20 mornings (38.2, 39.2 C) and 10-20 evening (38.9 C)
had none. HistCWU = 5 does not explain both; not resolved.
- No restart inside a comfort period was seen (cwu stayed above 42.7 until the period ended).
- The 10-24 13:21-13:45 cycle (cwu 41.4 to 43.2) was caused by our test writes, not by the program.
+11 -4
View File
@@ -23,10 +23,12 @@ unless marked otherwise.
|---|---|---|
| Temperatures | 4065 outdoor, 4066 indoor, 4067 CO/buffer, 4069 CWU, 4070/4071 ground in/out, 4072 pump outlet, 4199 avg outdoor | X10 |
| Targets now | 4201 room, 4202 CO supply, 4203 CWU | X10; show economy vs comfort period |
| State | 4227 pump (0 stop, 1 CO, 2 CWU, 3 both), 4226 program, 4221 alarm, 4073 pressure switch | |
| Outputs | 4112 CO, 4113 buffer, 4114 compressor (0/33/66/100), 4115 heater, 4116 circulation, 4117 ground, 4118 CWU | 0 or 100 |
| State | 4227 pump (0 stop, 1 CO, 2 CWU, 3 both), 4226 program, 4221 alarm, 4073 pressure switch | 4226 numbered as the PDF (1 = weekly), verified 2026-10-24 |
| Outputs | 4112 CO, 4113 buffer, 4114 compressor (0/33/66/100), 4115 heater signal (heater not connected), 4116 circulation, 4117 ground, 4118 CWU | 0 or 100 |
| Setpoints (F3) | 4131/4132 CO economy/comfort (5–35), 4143/4144 CWU economy/comfort (5–70) | whole °C |
| Protection | 4158 TmaxPc = 52 °C, 4149 TmaxCWU = 55, 4148 HistCWU = 5, 4146 PriorCWU = 1, 4147 heater delay = 20 | read 2026-10-23 |
| Protection | 4158 TmaxPc = 52 °C, 4149 TmaxCWU = 55, 4148 HistCWU = 5, 4146 PriorCWU = 1, 4147 heater signal delay = 20 | read 2026-10-23 |
| Compressor | 4166 zwlZSt(II) = 30 min (stage 2 delay; was 20, matching history, until the owner-approved one-time write of 2026-10-25, read back 30), 4165 zwlZSt(..) = 10 (likely stage 3, never seen), 4162 t_wylSpr = 5 min (minimum off, matches the 18:20 stop / 18:25 restart), 4160 t_zwlZalSpr = 20, 4161 t_zwlWylPdz = 20, 4163 t_minSpr = 160, 4164 t_maxSpr = 230 (units unknown); 4157 TminDz = −3, 4159 ∆TmaxDz = 10 | FC3 read 2026-10-25, 4166 written and read back 2026-10-25 |
| CWU extras | 4150 TpodbCWU = 5, 4151 t_podbCWU = 15, disinfection 4152–4154 = 7 days / 60 °C / 60 min (off: 4145 = 0), 4155/4156 t_zal/wylCCW = 2/2; 4246, 4247 read garbage (not on this firmware) | FC3 read 2026-10-25 |
Unknown: 4119 `WyCHL` reads −14308. Vendor-app findings, all unverified: [app-knowledge.md](app-knowledge.md).
@@ -44,6 +46,11 @@ the Frisko app. Party mode (4140 = 2, hours in 4142) is a simpler "heat now" ove
Slow morning rises with the pump off come from the solar collector.
- The pump outlet tops out at ~52 °C (TmaxPc), so the pump alone reaches ~47–48 °C in the tank.
Higher CWU values in history occur only in summer (solar).
- Setting CWU above what the pump reaches risks the electric heater (after the delay) and
- Setting CWU above what the pump reaches raises the controller's heater signal (after the delay; the heater is not connected) and risks
pressure-switch trips; three trips in one cycle latch a fault until manual reset (MR208-PC+ docs).
- Frisko phone app: direct Modbus TCP, LAN only, 200–300 ms between requests, 5 s timeout.
- History analysis of 2026-10 ([history-2026-10.md](history-2026-10.md)): the heater signal comes 20 min into a
CWU cycle (4147), `zb_state` 2 = that signal. The heater is not connected (owner, 2026-10-25); at the
same moment the compressor goes from stage 1 to 2 (the +0.9 kW meter step, three-phase); at an outlet
of 52 °C (TmaxPc) it stops; 4227 is unreliable for "heating CWU"; the controller clock
does not follow DST; period = target compared with the setpoints, valid only with 4226 = 1.
+21 -5
View File
@@ -33,14 +33,30 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
meter's converter speaks RTU over TCP); `rpi-measurements` stopped and disabled
- [ ] After the week: remove `rpi-measurements` and the old `home-pump.service` files
## Next
## Next: M2 Understand the controller
Done when: `docs/frisko/` reviewed, each open question answered or marked out of reach. Reads only;
a probe against the controller runs on the Pi, with the owner's approval, while the Frisko app is closed.
- [x] F3 limits agreed: CWU 5–48, CO 5–25 (gateway/README.md); 4226 numbering holds (history)
- [ ] DST, 2026-10-26: CWU comfort at ~10:25 / 16:55 local means the clock is still on CEST; then
the owner sets it and we note the date
- [ ] Timing probe: block response times (p50, p95) and errors at 1, 2 and 5 s cycles, ~10 min
each; what our connection does to a running Frisko app. Gives the poll rate and Odśwież limit
- [ ] Which pumps run in which state: outputs 4112, 4113, 4116–4118 and stage 4114 against 4227
over a week of `heat_pump`, including the first CO cycles
- [ ] Over the same week: 4222–4225 and 4228 values; the CWU start condition (40.8–42.8 °C vs none
at 38–39 °C)
- [ ] Read 4011 beside 4221 and 4073 (a new read-only field): does it exist on SE21 (M5 needs it)
- [ ] Weekly program: read 4167–4177 through the shared selector, or keep inferring it (F6); decide
## Later
| Milestone | Scope | Done when |
|---|---|---|
| M2 Understand the controller | Timing (safe poll rate, response times), which pumps run in which state, open questions in `docs/frisko/` | `docs/frisko/` reviewed; F3 limits agreed |
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; Traefik route, Krosno only and no login for now (ADR-009). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); open: `REGISTRY_TOKEN`, first release, the stack in homelab-compose (Arcane) | My father sees the heat pump and the energy on his phone |
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011), away held until a chosen date (ADR-012); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
| M4b Battery settings | F8 (ADR-013): Time of Use table and backup reserve from the Bateria view, FC16 with read-back, refused on stale or changed state; on branch/master and off until `deyeCommands.enabled` (gateway/README.md: Enabling battery commands) | Father changes the battery program from the app; first live write watched |
| 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, sign-in (ADR-014), on the internet since v2.0.0 (ADR-016). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); live at `pompa.mariuszpacek.pl` behind the sign-in (2026-10-25) | My father sees the heat pump and the energy on his phone |
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011), away held until a chosen date (ADR-012); CWU first, then CO; S1–S6. On master and enabled on the Pi since 2026-10-24 (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 master and enabled on the Pi since 2026-10-24 (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 |
+6 -5
View File
@@ -74,10 +74,10 @@ 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; the BME280 reading (temperature, humidity,
alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
pressure) of the living room. An "Odśwież" button asks for one extra poll of each device, at most once
a minute per device (the MK01 takes one client).
- F2 History for the last 7 days, and per-day heat pump statistics for 7 or 30 days (compressor, CWU, CO and heater hours,
a minute per device (the MK01 takes one client). It also shows the period (comfort or economy) of CWU and room.
- F2 History for the last 7 days, and per-day heat pump statistics for 7 or 30 days (compressor, CWU and CO 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
@@ -92,7 +92,8 @@ Functional:
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).
- F6 Show the weekly program as the history shows it (comfort intervals per weekday, from the polls; nothing is
asked of the controller, since reading its program needs selector writes). Editing it stays out.
- 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.
@@ -111,7 +112,7 @@ Safety, for every write:
Non-functional:
- N1 Reachable from outside only through Traefik with authentication.
- N1 Reachable from outside only through Traefik (Krosno addresses), and behind the app's own sign-in: local accounts and Keycloak, independent ([ADR-014](decisions/014-login.md)).
- N2 Recovers on its own from device, InfluxDB and tunnel outages.
- N3 Existing Grafana dashboards keep working.
- N4 Usable by my parents: large controls, few screens, Polish UI.
+12
View File
@@ -35,6 +35,18 @@ Podgląd opens with the alarm and one collapsible line per distinct IMGW warning
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.
## Login
`AuthGate` (`Auth.tsx`) asks `api/auth/me` first: 200 shows the app, 401 the login screen, any other failure "Brak połączenia z
serwerem" with a retry. The screen has the login and password form (always the way in when Keycloak is down) and, when
`api/auth/config` says `oidc`, "Zaloguj przez Keycloak" (a full page visit to `api/auth/oidc/start`; its failures return as
`#login?error=…`). If the config cannot be read the form still shows. A 401 from any poller or write (`api.ts` reports it)
ends the session: the app unmounts, so every poller stops, and the login screen says "Sesja wygasła"; the hash is kept, so
after login the same screen opens. "Wyloguj" is in Stan and the side panel.
When `api/auth/config` has `setup`, the form is "Utwórz pierwsze konto" (`api/auth/setup`; a 409 falls back to the login form).
The "Konta" card at the end of Ustawienia (`Accounts.tsx`, only with a login of our own) adds and deletes accounts and changes the own password.
## 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
+2 -3
View File
@@ -20,10 +20,9 @@ services:
traefik.http.routers.home-pump.tls.certresolver: http
traefik.http.routers.home-pump.service: home-pump
traefik.http.services.home-pump.loadbalancer.server.port: "8080"
# ADR-009: only Krosno may open it, and no client may name the user who changes a setpoint.
# ADR-009: only Krosno may open it; the backend signs users in itself (ADR-014).
traefik.http.middlewares.home-pump-krosno.ipwhitelist.sourcerange: ${KROSNO_SOURCES:?Krosno's address or range}
traefik.http.middlewares.home-pump-no-user.headers.customrequestheaders.Remote-User: ""
traefik.http.routers.home-pump.middlewares: home-pump-krosno,home-pump-no-user
traefik.http.routers.home-pump.middlewares: home-pump-krosno
networks:
home-pump:
+2
View File
@@ -20,6 +20,8 @@ server {
location /api/ {
proxy_pass ${BACKEND_URL};
# Do not set X-Forwarded-For here (no $proxy_add_x_forwarded_for): the backend's login throttle
# takes the entry Traefik added, TRUSTED_PROXY_HOPS=1, and nginx passes the header on unchanged.
# With the port: the backend compares Origin with Host before a write.
proxy_set_header Host $http_host;
proxy_read_timeout 30s;
+185
View File
@@ -0,0 +1,185 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { answer } from "./fixtures";
type Handler = (body: any) => Promise<Response>;
const bodyOf = (init?: RequestInit) => (init?.body ? JSON.parse(String(init.body)) : undefined);
const LONG = "dlugie-haslo-1";
const empty = () => Promise.resolve(new Response(null, { status: 204 }));
// A logged-in user and a list of accounts; POSTs go to the handlers by URL.
async function open(method: string, users: string[], posts: Record<string, Handler> = {}) {
const fetch = vi.fn((url: string, init?: RequestInit) => {
if (init?.method === "POST") return (posts[url] ?? (() => answer({}, 404)))(bodyOf(init));
if (url === "api/auth/me") return answer({ user: "mariusz", method });
if (url === "api/auth/users") return answer(users.map((username) => ({ username })));
return answer({}, 404);
});
vi.stubGlobal("fetch", fetch);
await mount();
return fetch;
}
async function mount() {
const { AuthGate } = await import("./Auth");
const { Accounts } = await import("./Accounts");
render(<AuthGate><Accounts /></AuthGate>);
}
const type = (label: string, value: string) => fireEvent.change(screen.getByLabelText(label), { target: { value } });
const click = (name: string) => fireEvent.click(screen.getByRole("button", { name }));
const fillNew = (name: string, password: string, repeat = password) => {
click("Dodaj konto");
type("Login", name);
type("Hasło", password);
type("Powtórz hasło", repeat);
click("Zapisz");
};
const changePassword = () => {
click("Zmień moje hasło");
type("Obecne hasło", "stare-haslo-1");
type("Nowe hasło", "nowe-haslo-123");
type("Powtórz hasło", "nowe-haslo-123");
click("Zapisz");
};
beforeEach(() => {
vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener() {}, removeEventListener() {} }));
});
describe("Accounts", () => {
it("lists the accounts and marks the own one", async () => {
await open("local", ["mariusz", "ola"]);
expect(await screen.findByText("ola")).toBeInTheDocument();
expect(screen.getByText("(Ty)")).toBeInTheDocument();
});
it("is not shown on a server without login", async () => {
const fetch = await open("none", []);
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith("api/auth/me", expect.anything()));
expect(screen.queryByText("Konta")).toBeNull();
expect(fetch.mock.calls.map(([url]) => url)).not.toContain("api/auth/users");
});
it("adds an account and lists the accounts again", async () => {
const post = vi.fn(() => answer({ username: "ola" }));
const fetch = await open("local", ["mariusz"], { "api/auth/users": post });
await screen.findByText("mariusz");
fillNew("ola", "dlugie-haslo-1");
expect(await screen.findByText("Dodano konto")).toBeInTheDocument();
expect(post).toHaveBeenCalledWith({ username: "ola", password: LONG });
expect(fetch.mock.calls.filter(([url, init]) => url === "api/auth/users" && init?.method !== "POST")).toHaveLength(2);
});
it.each([
["exists", () => answer({ error: "exists" }, 409), "Takie konto już istnieje"],
["weak", () => answer({ error: "weak_password" }, 400), "Hasło jest za słabe (co najmniej 10 znaków)"],
])("says so when adding fails: %s", async (_name, reply, text) => {
await open("local", ["mariusz"], { "api/auth/users": reply });
await screen.findByText("mariusz");
fillNew("ola", "dlugie-haslo-1");
expect(await screen.findByRole("alert")).toHaveTextContent(text);
});
it("checks the repeated password before sending", async () => {
const post = vi.fn(() => answer({}));
await open("local", ["mariusz"], { "api/auth/users": post });
await screen.findByText("mariusz");
fillNew("ola", "dlugie-haslo-1", "inne-haslo-123");
expect(await screen.findByRole("alert")).toHaveTextContent("Hasła nie są takie same");
expect(post).not.toHaveBeenCalled();
});
it("changes the own password", async () => {
const post = vi.fn(empty);
await open("local", ["mariusz"], { "api/auth/password": post });
await screen.findByText("mariusz");
changePassword();
expect(await screen.findByText("Hasło zmienione")).toBeInTheDocument();
expect(post).toHaveBeenCalledWith({ current: "stare-haslo-1", new: "nowe-haslo-123" });
});
it.each([
["401", () => answer({ error: "invalid" }, 401), "Nieprawidłowe obecne hasło"],
["400", () => answer({ error: "weak_password" }, 400), "Hasło jest za słabe (co najmniej 10 znaków)"],
["429", () => answer({ error: "throttled", retryAfterSeconds: 120 }, 429), "Za dużo prób – spróbuj za 2 min"],
])("says so when the password change fails: %s", async (_name, reply, text) => {
await open("local", ["mariusz"], { "api/auth/password": reply });
await screen.findByText("mariusz");
changePassword();
expect(await screen.findByRole("alert")).toHaveTextContent(text);
});
it("offers no password change to a Keycloak user", async () => {
await open("oidc", ["mariusz"]);
await screen.findByText("mariusz");
expect(screen.getByRole("button", { name: "Dodaj konto" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Zmień moje hasło" })).toBeNull();
});
it("deletes only after the confirmation", async () => {
const post = vi.fn(empty);
await open("local", ["mariusz", "ola"], { "api/auth/users/ola/delete": post });
await screen.findByText("ola");
click("Usuń ola");
expect(screen.getByText("Usunąć konto ola?")).toBeInTheDocument();
expect(post).not.toHaveBeenCalled();
click("Anuluj");
expect(screen.queryByText("Usunąć konto ola?")).toBeNull();
click("Usuń ola");
click("Tak, usuń");
expect(await screen.findByText("Usunięto konto ola")).toBeInTheDocument();
expect(post).toHaveBeenCalledTimes(1);
});
it("does not delete the last account", async () => {
await open("local", ["mariusz"]);
await screen.findByText("mariusz");
expect(screen.getByRole("button", { name: "Usuń mariusz" })).toBeDisabled();
});
it("says so when the server refuses to delete the last account", async () => {
await open("local", ["mariusz", "ola"], { "api/auth/users/ola/delete": () => answer({ error: "last_user" }, 409) });
await screen.findByText("ola");
click("Usuń ola");
click("Tak, usuń");
expect(await screen.findByText("Nie można usunąć ostatniego konta")).toBeInTheDocument();
});
it("returns to the login screen when the own account is deleted", async () => {
let deleted = false;
vi.stubGlobal("fetch", vi.fn((url: string, init?: RequestInit) => {
if (init?.method === "POST") {
deleted = true;
return empty();
}
if (url === "api/auth/me") return answer({ user: "mariusz", method: "local" });
if (url === "api/auth/users") return deleted ? answer({ error: "unauthenticated" }, 401) : answer([{ username: "mariusz" }, { username: "ola" }]);
return answer({ local: true, oidc: false });
}));
await mount();
await screen.findByText("ola");
click("Usuń mariusz");
click("Tak, usuń");
expect(await screen.findByText("Sesja wygasła – zaloguj się ponownie")).toBeInTheDocument();
});
});
+130
View File
@@ -0,0 +1,130 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { getJson, postAuth } from "./api";
import { accountFailure, BUTTON, FIELD, newPasswordProblem, useSession } from "./Auth";
import { Card } from "./ui";
type Form = "add" | "password" | null;
function Field({ label, value, onChange, auto, type = "password" }: {
label: string; value: string; onChange: (value: string) => void; auto: string; type?: string;
}) {
return (
<label className="block">
<span className="mb-1 block font-semibold">{label}</span>
<input value={value} onChange={(e) => onChange(e.target.value)} type={type} autoComplete={auto} required
autoCapitalize="none" spellCheck={false} className={FIELD} />
</label>
);
}
// Sends the fields once the new password passes the checks; says what went wrong, or closes the form.
function AccountForm({ title, fields, send, onDone }: {
title: string; fields: "add" | "password";
send: (values: Record<string, string>) => Promise<{ status: number; body: any }>; onDone: () => void;
}) {
const [values, setValues] = useState<Record<string, string>>({});
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const set = (key: string) => (value: string) => setValues((now) => ({ ...now, [key]: value }));
const secret = (fields === "add" ? values.password : values.new) ?? "";
const submit = async (event: FormEvent) => {
event.preventDefault();
const problem = newPasswordProblem(secret, values.repeat ?? "");
if (problem) return setError(problem);
setSending(true);
setError("");
const { status, body } = await send(values);
setSending(false);
if (status === 200 || status === 204) return onDone();
setError(status === 401 ? "Nieprawidłowe obecne hasło" : accountFailure(status, body));
};
return (
<form onSubmit={submit} className="mt-3 space-y-3 border-t border-edge pt-3">
<h3 className="font-semibold">{title}</h3>
{fields === "add" ? (
<>
<Field label="Login" value={values.username ?? ""} onChange={set("username")} auto="off" type="text" />
<Field label="Hasło" value={values.password ?? ""} onChange={set("password")} auto="new-password" />
</>
) : (
<>
<Field label="Obecne hasło" value={values.current ?? ""} onChange={set("current")} auto="current-password" />
<Field label="Nowe hasło" value={values.new ?? ""} onChange={set("new")} auto="new-password" />
</>
)}
<Field label="Powtórz hasło" value={values.repeat ?? ""} onChange={set("repeat")} auto="new-password" />
{error && <p role="alert" className="font-semibold text-alarm">{error}</p>}
<button type="submit" disabled={sending} className={`${BUTTON} bg-brand text-white dark:text-side`}>Zapisz</button>
</form>
);
}
const SMALL = "min-h-11 rounded-xl border-2 border-brand px-3 font-bold text-brand disabled:opacity-40";
/** The accounts that may log in: add, change the own password, delete. Nothing without a login of our own. */
export function Accounts() {
const session = useSession();
const [users, setUsers] = useState<string[]>();
const [form, setForm] = useState<Form>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const [message, setMessage] = useState("");
const managed = session != null && session.method !== "none";
const load = useCallback(() => {
getJson<{ username: string }[]>("api/auth/users").then(
(list) => setUsers(list.map((u) => u.username)), () => setMessage("Nie udało się pobrać kont"));
}, []);
useEffect(() => {
if (managed) load();
}, [managed, load]);
if (!managed) return null;
const done = (text: string) => () => {
setForm(null);
setMessage(text);
load();
};
// Deleting yourself ends the session: the list request that follows gets 401 and the login screen shows.
const remove = async (name: string) => {
setConfirming(null);
const { status, body } = await postAuth(`api/auth/users/${encodeURIComponent(name)}/delete`);
if (status === 204) return done(`Usunięto konto ${name}`)();
setMessage(body?.error === "last_user" ? "Nie można usunąć ostatniego konta" : accountFailure(status, body));
};
const toggle = (next: Form) => setForm(form === next ? null : next);
return (
<Card title="Konta" className="mt-4">
{users?.map((name) => (
<div key={name} className="flex items-center justify-between gap-3 border-t border-edge py-2 first:border-t-0">
<span>{name}{name === session.user && <span className="text-soft"> (Ty)</span>}</span>
{confirming === name ? (
<span className="flex flex-wrap items-center justify-end gap-2">
<span className="text-sm font-semibold">Usunąć konto {name}?</span>
<button onClick={() => remove(name)} className={`${SMALL} !border-alarm bg-alarm !text-white`}>Tak, usuń</button>
<button onClick={() => setConfirming(null)} className={SMALL}>Anuluj</button>
</span>
) : (
<button onClick={() => setConfirming(name)} disabled={users.length < 2} aria-label={`Usuń ${name}`} className={SMALL}>Usuń</button>
)}
</div>
))}
{message && <p role="status" className="mt-2 text-sm font-semibold text-soft">{message}</p>}
<div className="mt-3 flex flex-wrap gap-2">
<button onClick={() => toggle("add")} aria-pressed={form === "add"} className={SMALL}>Dodaj konto</button>
{session.method === "local" && (
<button onClick={() => toggle("password")} aria-pressed={form === "password"} className={SMALL}>Zmień moje hasło</button>
)}
</div>
{form === "add" && (
<AccountForm title="Nowe konto" fields="add" onDone={done("Dodano konto")}
send={({ username, password }) => postAuth("api/auth/users", { username, password })} />
)}
{form === "password" && (
<AccountForm title="Zmiana hasła" fields="password" onDone={done("Hasło zmienione")}
send={({ current, new: next }) => postAuth("api/auth/password", { current, new: next })} />
)}
</Card>
);
}
+2
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect, useState, type ComponentType } from "react";
import { Account } from "./Auth";
import { celsius, hour, longDate } from "./format";
import { Icon, type IconName } from "./icons";
import { Energy } from "./Energy";
@@ -70,6 +71,7 @@ function SideFooter() {
</div>
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
<ThemeSwitch className="bg-white/10" />
<Account className="text-sm text-white/80" />
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
<Dot tone={offline ? "bad" : old ? "warn" : "ok"} live={!offline && !old} />
<div className="text-sm">
+304
View File
@@ -0,0 +1,304 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { answer, example } from "./fixtures";
const ME = { user: "mariusz", method: "local" };
const LONG = "dlugie-haslo-1";
const CONFIG = { local: true, oidc: true };
type Handler = (body: unknown) => Promise<Response>;
// Answers by "METHOD url"; what is not listed is a 404. Replace entries to change the server's mind.
function server(routes: Record<string, Handler | unknown>) {
const fetch = vi.fn((url: string, init?: RequestInit) => {
const route = routes[`${init?.method ?? "GET"} ${url}`];
if (typeof route === "function") return (route as Handler)(init?.body ? JSON.parse(String(init.body)) : undefined);
return route === undefined ? answer({}, 404) : answer(route);
});
vi.stubGlobal("fetch", fetch);
return fetch;
}
const unauthenticated = () => answer({ error: "unauthenticated" }, 401);
async function open(path = "") {
history.replaceState(null, "", `/${path}`);
const { AuthGate } = await import("./Auth");
const { App } = await import("./App");
render(<AuthGate><App /></AuthGate>);
}
const login = (name = "mariusz", password = "tajne") => {
fireEvent.change(screen.getByLabelText("Login"), { target: { value: name } });
fireEvent.change(screen.getByLabelText("Hasło"), { target: { value: password } });
fireEvent.click(screen.getByRole("button", { name: "Zaloguj" }));
};
const loginScreen = () => screen.findByRole("button", { name: "Zaloguj" });
beforeEach(() => {
history.replaceState(null, "", "/");
vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener() {}, removeEventListener() {} }));
});
describe("login", () => {
it("shows the login screen, not the app, without a session", async () => {
const fetch = server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": { local: true, oidc: false } });
await open();
expect(await loginScreen()).toBeEnabled();
expect(screen.getByLabelText("Login")).toHaveAttribute("autocomplete", "username");
expect(screen.getByLabelText("Hasło")).toHaveAttribute("autocomplete", "current-password");
expect(screen.queryByRole("navigation")).toBeNull();
expect(screen.queryByText("Zaloguj przez Keycloak")).toBeNull();
expect(fetch.mock.calls.map(([url]) => url).filter((url) => !url.startsWith("api/auth"))).toEqual([]);
});
it("shows the app after a successful login, and sends the password once with the page header", async () => {
const post = vi.fn(() => answer(ME));
const fetch = server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG, "POST api/auth/login": post });
await open();
await loginScreen();
login();
expect(await screen.findByRole("navigation", { name: "Ekrany" })).toBeInTheDocument();
expect(post).toHaveBeenCalledWith({ username: "mariusz", password: "tajne" });
const init = fetch.mock.calls.find(([url]) => url === "api/auth/login")![1]!;
expect(init.headers).toMatchObject({ "X-Requested-With": "home-pump" });
});
it("lets the password be shown and submits with Enter", async () => {
const post = vi.fn(() => answer(ME));
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG, "POST api/auth/login": post });
await open();
await loginScreen();
fireEvent.click(screen.getByRole("button", { name: "Pokaż" }));
expect(screen.getByLabelText("Hasło")).toHaveAttribute("type", "text");
fireEvent.change(screen.getByLabelText("Login"), { target: { value: "a" } });
fireEvent.change(screen.getByLabelText("Hasło"), { target: { value: "b" } });
fireEvent.submit(screen.getByLabelText("Hasło").closest("form")!);
await screen.findByRole("navigation", { name: "Ekrany" });
expect(post).toHaveBeenCalledWith({ username: "a", password: "b" });
});
it.each([
["a wrong password", () => answer({ error: "invalid" }, 401), "Nieprawidłowy login lub hasło"],
["too many tries", () => answer({ error: "throttled", retryAfterSeconds: 130 }, 429), "Za dużo prób – spróbuj za 3 min"],
["a lost network", () => Promise.reject(new TypeError("offline")), "Brak połączenia z serwerem"],
])("says so on %s and lets the user try again", async (_name, reply, text) => {
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG, "POST api/auth/login": reply });
await open();
await loginScreen();
login();
expect(await screen.findByRole("alert")).toHaveTextContent(text);
expect(screen.getByRole("button", { name: "Zaloguj" })).toBeEnabled();
expect(screen.queryByRole("navigation")).toBeNull();
});
it("disables the button while the login is sent", async () => {
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG, "POST api/auth/login": () => new Promise(() => {}) });
await open();
await loginScreen();
login();
await vi.waitFor(() => expect(screen.getByRole("button", { name: "Zaloguj" })).toBeDisabled());
});
it("offers Keycloak only when enabled, as a full page navigation", async () => {
const assign = vi.fn();
vi.stubGlobal("location", { ...location, assign, hash: "" });
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG });
await open();
fireEvent.click(await screen.findByRole("button", { name: "Zaloguj przez Keycloak" }));
expect(screen.getByText("lub")).toBeInTheDocument();
expect(screen.getByLabelText("Login")).toBeInTheDocument();
expect(assign).toHaveBeenCalledWith("api/auth/oidc/start");
});
it("hides the form when local login is off", async () => {
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": { local: false, oidc: true } });
await open();
expect(await screen.findByRole("button", { name: "Zaloguj przez Keycloak" })).toBeInTheDocument();
expect(screen.queryByLabelText("Login")).toBeNull();
expect(screen.queryByText("lub")).toBeNull();
});
it("still shows the form when the config cannot be read", async () => {
server({ "GET api/auth/me": unauthenticated });
await open();
expect(await loginScreen()).toBeInTheDocument();
expect(screen.queryByText("Zaloguj przez Keycloak")).toBeNull();
});
it.each([
["oidc_unavailable", "Keycloak jest niedostępny – zaloguj się loginem i hasłem"],
["oidc_denied", "Brak uprawnień do aplikacji"],
["oidc_failed", "Logowanie przez Keycloak nie powiodło się"],
])("explains %s from the address", async (code, text) => {
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG });
await open(`#login?error=${code}`);
expect(await screen.findByLabelText("Login")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent(text);
});
it.each(["__proto__", "constructor", "toString"])("ignores the unknown error code %s", async (code) => {
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": CONFIG });
await open(`#login?error=${code}`);
expect(await screen.findByLabelText("Login")).toBeInTheDocument();
expect(screen.queryByRole("alert")).toBeNull();
});
it("says local login is off when the server answers 404", async () => {
server({ "GET api/auth/me": unauthenticated, "POST api/auth/login": () => answer({}, 404) });
await open();
await loginScreen();
login();
expect(await screen.findByRole("alert")).toHaveTextContent("Logowanie loginem i hasłem jest wyłączone");
});
it("offers a reload instead of an empty screen when no way to log in is enabled", async () => {
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": { local: false, oidc: false } });
await open();
expect(await screen.findByRole("button", { name: "Wczytaj ponownie" })).toBeInTheDocument();
expect(screen.queryByLabelText("Login")).toBeNull();
});
it("says there is no connection instead of showing the login when the server cannot be reached", async () => {
server({ "GET api/auth/me": () => Promise.reject(new TypeError("offline")) });
await open();
expect(await screen.findByRole("alert")).toHaveTextContent("Brak połączenia z serwerem");
expect(screen.queryByLabelText("Login")).toBeNull();
expect(screen.getByRole("button", { name: "Spróbuj ponownie" })).toBeInTheDocument();
});
});
describe("session", () => {
const health = example("health");
it("returns to the login screen when a poller gets 401, and to the same screen after login", async () => {
let expired = false;
server({
"GET api/auth/me": ME,
"GET api/health": () => (expired ? unauthenticated() : answer(health)),
"GET api/auth/config": CONFIG,
"POST api/auth/login": ME,
});
await open("#status");
expect(await screen.findByRole("heading", { name: "Stan" })).toBeInTheDocument();
expired = true;
await act(async () => document.dispatchEvent(new Event("visibilitychange")));
expect(await screen.findByText("Sesja wygasła – zaloguj się ponownie")).toBeInTheDocument();
expect(screen.queryByRole("navigation")).toBeNull();
expired = false;
await loginScreen();
login();
expect(await screen.findByRole("heading", { name: "Stan" })).toBeInTheDocument();
expect(location.hash).toBe("#status");
});
it("shows the app without account or logout when the server has no login", async () => {
server({ "GET api/auth/me": { user: "phone", method: "none" }, "GET api/health": health });
await open("#status");
expect(await screen.findByRole("heading", { name: "Stan" })).toBeInTheDocument();
expect(screen.queryByText("Zalogowano:")).toBeNull();
expect(screen.queryByRole("button", { name: "Wyloguj" })).toBeNull();
});
it("shows who is logged in and logs out", async () => {
const out = vi.fn(() => new Response(null, { status: 204 }));
server({
"GET api/auth/me": ME, "GET api/health": health, "GET api/auth/config": CONFIG,
"POST api/auth/logout": out as unknown as Handler,
});
await open("#status");
expect((await screen.findAllByText("Zalogowano:"))[0].parentElement).toHaveTextContent("Zalogowano: mariusz");
fireEvent.click(screen.getAllByRole("button", { name: "Wyloguj" })[0]);
expect(await loginScreen()).toBeInTheDocument();
expect(out).toHaveBeenCalled();
expect(screen.queryByText("Sesja wygasła – zaloguj się ponownie")).toBeNull();
});
});
describe("first account", () => {
const SETUP = { local: true, oidc: true, setup: true };
const fill = (password: string, repeat: string) => {
fireEvent.change(screen.getByLabelText("Login"), { target: { value: "mariusz" } });
fireEvent.change(screen.getByLabelText("Hasło"), { target: { value: password } });
fireEvent.change(screen.getByLabelText("Powtórz hasło"), { target: { value: repeat } });
fireEvent.click(screen.getByRole("button", { name: "Utwórz konto" }));
};
it("offers the creation instead of the login form, with Keycloak below, and opens the app", async () => {
const post = vi.fn(() => answer(ME));
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": post });
await open();
expect(await screen.findByText("Utwórz pierwsze konto")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Zaloguj" })).toBeNull();
expect(screen.getByRole("button", { name: "Zaloguj przez Keycloak" })).toBeInTheDocument();
fill("dlugie-haslo-1", "dlugie-haslo-1");
expect(await screen.findByRole("navigation", { name: "Ekrany" })).toBeInTheDocument();
expect(post).toHaveBeenCalledWith({ username: "mariusz", password: LONG });
});
it.each([
["too short", "krotkie", "krotkie", "Hasło musi mieć co najmniej 10 znaków"],
["different", "dlugie-haslo-1", "dlugie-haslo-2", "Hasła nie są takie same"],
])("does not send a password that is %s", async (_name, password, repeat, text) => {
const post = vi.fn(() => answer(ME));
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": post });
await open();
await screen.findByText("Utwórz pierwsze konto");
fill(password, repeat);
expect(await screen.findByRole("alert")).toHaveTextContent(text);
expect(post).not.toHaveBeenCalled();
});
it("shows the login form when somebody created the account first", async () => {
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": () => answer({ error: "setup_done" }, 409) });
await open();
await screen.findByText("Utwórz pierwsze konto");
fill("dlugie-haslo-1", "dlugie-haslo-1");
expect(await loginScreen()).toBeInTheDocument();
expect(screen.getByText("Konto już istnieje – zaloguj się")).toBeInTheDocument();
});
it.each([
["a weak password", () => answer({ error: "weak_password" }, 400), "Hasło jest za słabe (co najmniej 10 znaków)"],
["a bad login", () => answer({ error: "bad_username" }, 400), "Nieprawidłowy login"],
["too many tries", () => answer({ error: "throttled", retryAfterSeconds: 60 }, 429), "Za dużo prób – spróbuj za 1 min"],
["a lost network", () => Promise.reject(new TypeError("offline")), "Brak połączenia z serwerem"],
])("says so on %s from the server", async (_name, reply, text) => {
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": reply });
await open();
await screen.findByText("Utwórz pierwsze konto");
fill("dlugie-haslo-1", "dlugie-haslo-1");
expect(await screen.findByRole("alert")).toHaveTextContent(text);
});
});
+244
View File
@@ -0,0 +1,244 @@
import { createContext, useCallback, useContext, useEffect, useState, type FormEvent, type ReactNode } from "react";
import { getJson, HttpError, postAuth, whenSessionExpires } from "./api";
import { Icon } from "./icons";
import { Banner, Card, Loading } from "./ui";
interface Session { user: string; method: "local" | "oidc" | "none" }
interface AuthConfig { local: boolean; oidc: boolean; setup?: boolean }
type State =
| { kind: "loading" }
| { kind: "unreachable" }
| { kind: "out"; expired: boolean }
| { kind: "in"; session: Session };
const SessionContext = createContext<{ user: string; method: Session["method"]; logout: () => void } | null>(null);
// A Map, so a code such as "__proto__" finds nothing.
const OIDC_ERRORS = new Map([
["oidc_unavailable", "Keycloak jest niedostępny – zaloguj się loginem i hasłem"],
["oidc_denied", "Brak uprawnień do aplikacji"],
["oidc_failed", "Logowanie przez Keycloak nie powiodło się"],
]);
// Keycloak sends the browser back to "#login?error=…".
const oidcError = () => OIDC_ERRORS.get(new URLSearchParams(location.hash.split("?")[1]).get("error") ?? "");
export const FIELD = "min-h-12 w-full rounded-xl border-2 border-off bg-page px-3 text-lg text-ink focus:border-brand focus:outline-none";
export const BUTTON = "min-h-12 w-full rounded-xl border-2 border-brand px-3 text-lg font-bold transition active:scale-[0.98] disabled:opacity-40";
export const MIN_PASSWORD = 10;
/** The message for a new password that cannot be sent, or "" when it can. */
export function newPasswordProblem(password: string, repeat: string) {
if (password.length < MIN_PASSWORD) return `Hasło musi mieć co najmniej ${MIN_PASSWORD} znaków`;
return password === repeat ? "" : "Hasła nie są takie same";
}
/** What the server's refusal of a new account or password means. */
export function accountFailure(status: number, body: { error?: string; retryAfterSeconds?: number } | null) {
if (status === 400) return body?.error === "weak_password" ? `Hasło jest za słabe (co najmniej ${MIN_PASSWORD} znaków)` : "Nieprawidłowy login";
if (status === 409) return "Takie konto już istnieje";
return failure(status, body);
}
function failure(status: number, body: { retryAfterSeconds?: number } | null) {
if (status === 0) return "Brak połączenia z serwerem";
if (status === 429) return `Za dużo prób – spróbuj za ${Math.max(1, Math.ceil((body?.retryAfterSeconds ?? 0) / 60))} min`;
if (status === 404) return "Logowanie loginem i hasłem jest wyłączone";
return status === 401 ? "Nieprawidłowy login lub hasło" : "Logowanie nie powiodło się, spróbuj ponownie";
}
function Screen({ children }: { children: ReactNode }) {
return (
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center gap-5 px-4 py-8">
<div className="flex items-center gap-3">
<span className="grid size-12 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-7" /></span>
<div>
<h1 className="text-2xl font-bold leading-tight">Dom Szczepańcowa</h1>
<p className="text-soft">Pompa ciepła i energia</p>
</div>
</div>
{children}
</main>
);
}
function LoginForm({ onLogin }: { onLogin: (session: Session) => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [shown, setShown] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const submit = async (event: FormEvent) => {
event.preventDefault();
setSending(true);
setError("");
const { status, body } = await postAuth("api/auth/login", { username, password });
if (status === 200) return onLogin(body);
setError(failure(status, body));
setSending(false);
};
return (
<form onSubmit={submit} className="space-y-4">
<label className="block">
<span className="mb-1 block font-semibold">Login</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required
autoCapitalize="none" spellCheck={false} className={FIELD} />
</label>
<label className="block">
<span className="mb-1 block font-semibold">Hasło</span>
<span className="flex gap-2">
<input value={password} onChange={(e) => setPassword(e.target.value)} type={shown ? "text" : "password"}
autoComplete="current-password" required className={FIELD} />
<button type="button" onClick={() => setShown(!shown)} aria-pressed={shown}
className="min-h-12 shrink-0 rounded-xl border-2 border-off px-3 font-semibold text-soft aria-pressed:border-brand aria-pressed:text-brand">
{shown ? "Ukryj" : "Pokaż"}
</button>
</span>
</label>
{error && <p role="alert" className="font-semibold text-alarm">{error}</p>}
<button type="submit" disabled={sending} className={`${BUTTON} bg-brand text-white dark:text-side`}>Zaloguj</button>
</form>
);
}
function SetupForm({ onLogin, onExists }: { onLogin: (session: Session) => void; onExists: () => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const submit = async (event: FormEvent) => {
event.preventDefault();
const problem = newPasswordProblem(password, repeat);
if (problem) return setError(problem);
setSending(true);
setError("");
const { status, body } = await postAuth("api/auth/setup", { username, password });
if (status === 200) return onLogin(body);
if (status === 409) return onExists();
setError(accountFailure(status, body));
setSending(false);
};
return (
<form onSubmit={submit} className="space-y-4">
<h2 className="text-xl font-bold">Utwórz pierwsze konto</h2>
<label className="block">
<span className="mb-1 block font-semibold">Login</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required
autoCapitalize="none" spellCheck={false} className={FIELD} />
</label>
<label className="block">
<span className="mb-1 block font-semibold">Hasło</span>
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" autoComplete="new-password" required className={FIELD} />
</label>
<label className="block">
<span className="mb-1 block font-semibold">Powtórz hasło</span>
<input value={repeat} onChange={(e) => setRepeat(e.target.value)} type="password" autoComplete="new-password" required className={FIELD} />
</label>
{error && <p role="alert" className="font-semibold text-alarm">{error}</p>}
<button type="submit" disabled={sending} className={`${BUTTON} bg-brand text-white dark:text-side`}>Utwórz konto</button>
</form>
);
}
function Login({ expired, onLogin }: { expired: boolean; onLogin: (session: Session) => void }) {
// The form is the way in when Keycloak is down, so it also shows when the config cannot be read.
const [config, setConfig] = useState<AuthConfig>();
useEffect(() => {
getJson<AuthConfig>("api/auth/config").then(setConfig, () => setConfig({ local: true, oidc: false }));
}, []);
const [accountExists, setAccountExists] = useState(false);
const keycloakProblem = oidcError();
return (
<Screen>
{expired && <Banner tone="warn" compact>Sesja wygasła – zaloguj się ponownie</Banner>}
{accountExists && <Banner tone="ok" compact>Konto już istnieje – zaloguj się</Banner>}
{keycloakProblem && <Banner tone="bad" compact>{keycloakProblem}</Banner>}
{!config ? <Loading /> : !config.local && !config.oidc ? (
<>
<p className="font-semibold">Logowanie jest teraz niedostępne.</p>
<button type="button" onClick={() => location.reload()} className={`${BUTTON} bg-panel text-brand`}>Wczytaj ponownie</button>
</>
) : (
<>
{config.local && (config.setup && !accountExists
? <SetupForm onLogin={onLogin} onExists={() => setAccountExists(true)} />
: <LoginForm onLogin={onLogin} />)}
{config.local && config.oidc && (
<p className="flex items-center gap-3 text-soft before:h-px before:flex-1 before:bg-edge after:h-px after:flex-1 after:bg-edge">lub</p>
)}
{config.oidc && (
<button type="button" onClick={() => location.assign("api/auth/oidc/start")} className={`${BUTTON} bg-panel text-brand`}>
Zaloguj przez Keycloak
</button>
)}
</>
)}
</Screen>
);
}
/** Shows the app only for a logged-in user; otherwise the login screen. */
export function AuthGate({ children }: { children: ReactNode }) {
const [state, setState] = useState<State>({ kind: "loading" });
const load = useCallback(() => {
setState({ kind: "loading" });
getJson<Session>("api/auth/me").then(
(session) => setState({ kind: "in", session }),
(error) => setState(error instanceof HttpError && error.status === 401 ? { kind: "out", expired: false } : { kind: "unreachable" }),
);
}, []);
useEffect(() => {
whenSessionExpires(() => setState((now) => (now.kind === "in" ? { kind: "out", expired: true } : now)));
load();
}, [load]);
const logout = useCallback(async () => {
await postAuth("api/auth/logout");
setState({ kind: "out", expired: false });
}, []);
switch (state.kind) {
case "loading": return <Screen><Loading /></Screen>;
case "unreachable":
return (
<Screen>
<Banner tone="bad" compact>Brak połączenia z serwerem</Banner>
<button type="button" onClick={load} className={`${BUTTON} bg-panel text-brand`}>Spróbuj ponownie</button>
</Screen>
);
case "out":
return <Login expired={state.expired} onLogin={(session) => {
// Back to where the user was; the "#login?error=…" of a failed Keycloak try is no screen.
if (location.hash.startsWith("#login")) history.replaceState(null, "", location.pathname + location.search);
setState({ kind: "in", session });
}} />;
case "in":
return <SessionContext value={{ ...state.session, logout }}>{children}</SessionContext>;
}
}
/** The logged-in user, for the screens that manage accounts; null outside a login. */
export const useSession = () => useContext(SessionContext);
/** "Zalogowano: …" with the logout button; nothing outside a login (the tests of single screens). */
export function Account({ className = "", card = false }: { className?: string; card?: boolean }) {
const session = useSession();
if (!session || session.method === "none") return null; // no login in a development setup
const line = (
<div className={`flex items-center justify-between gap-3 ${className}`}>
<p>Zalogowano: <b className="font-semibold">{session.user}</b></p>
<button onClick={session.logout}
className="min-h-11 rounded-xl border-2 border-current px-3 font-bold">Wyloguj</button>
</div>
);
return card ? <Card title="Konto">{line}</Card> : line;
}
+2 -1
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { celsius, degrees, number } from "./format";
import { celsius, degrees, number, periodText } from "./format";
import type { HeatPump, PiSensor } from "./types";
import { Card, Row } from "./ui";
@@ -30,6 +30,7 @@ export function TemperatureDetails({ hp, pi }: { hp: HeatPump; pi: PiSensor | un
<Row label="Ciśnienie" value={reading(pi?.pressure, 0, "hPa")} />
<Row label="Pokój według pompy" value={celsius(t.indoor)} />
<Row label="Pokój, cel teraz" value={celsius(targets.room)} />
<Row label="Pokój, strefa" value={periodText(hp.periods.room, hp.periods.programText) ?? "—"} />
<Row label="Na zewnątrz, średnia" value={celsius(t.outdoorAvg)} />
</Group>
<Group title="Woda i ogrzewanie">
+31 -1
View File
@@ -109,6 +109,17 @@ describe("Energy details", () => {
expect(screen.getByRole("button", { name: /Falownik Fronius/ })).not.toHaveTextContent("PV");
});
it("shows the house consumption balance in the stat, the flow and the day donut", async () => {
serveEnergy();
const Energy = await energy();
render(<Energy />);
const stat = (await screen.findByText("Zużycie domu")).closest("section")!;
expect(stat).toHaveTextContent("365 W");
expect(screen.getAllByText("365 W").length).toBeGreaterThan(1);
expect(screen.getAllByText("28,4 kWh").length).toBeGreaterThan(0);
});
it("dims the values of a source that has gone quiet", async () => {
justAfterFixture();
const e = example<EnergyData>("energy");
@@ -120,7 +131,7 @@ describe("Energy details", () => {
const stats = (await screen.findByText("Zużycie domu")).closest("section")!;
expect(screen.getByText("Produkcja PV", { selector: "p" }).closest("section")).toHaveClass("opacity-60");
expect(screen.getAllByText("Bateria", { selector: "p" })[0].closest("section")).toHaveClass("opacity-60");
expect(stats).not.toHaveClass("opacity-60");
expect(stats).toHaveClass("opacity-60");
});
it("opens the groups of a device and closes by button, backdrop and Escape", async () => {
@@ -225,3 +236,22 @@ describe("Energy flow arrows", () => {
expect(screen.getByText("Słońce zasila dom: 2,5 kW")).toBeInTheDocument();
});
});
describe("Deye summary", () => {
it("adds only the string powers, not their voltages and currents", async () => {
const { deviceSummary } = await import("./Energy");
const item = (label: string, value: number, unit: string) => ({ label, value, unit, digits: 0 });
const deye = {
id: "deye", name: "Falownik Deye", time: null, stale: false,
groups: [
{ title: "Bateria", items: [item("Naładowanie", 99, "%")] },
{ title: "Panele PV", items: [
item("PV1 moc", 2870, "W"), item("PV1 napięcie", 497.7, "V"), item("PV1 prąd", 5.6, "A"),
item("PV2 moc", 3011, "W"), item("PV2 napięcie", 548.5, "V"), item("PV2 prąd", 5.3, "A"),
] },
],
};
expect(deviceSummary(deye, undefined)).toBe("Bateria 99 % · PV 5,9 kW");
});
});
+8 -7
View File
@@ -108,7 +108,7 @@ export function flowSummary(e: EnergyData) {
// A source without a fresh reading keeps its last value on the screen, so the value is dimmed.
function quietNodes(e: EnergyData): string[] {
if (isStale(e.time)) return Object.keys(NODES);
return silentInverters(e).flatMap(({ id }) => (id === "deye" ? ["pv", "battery"] : ["pv"]));
return silentInverters(e).flatMap(({ id }) => (id === "deye" ? ["pv", "battery", "house"] : ["pv", "house"]));
}
function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void }) {
@@ -123,7 +123,7 @@ function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void })
<Flow from="battery" to="house" watts={e.batteryPowerW} />
<Node id="pv" dim={dim("pv")} value={power(e.pvPowerW)} />
<Node id="grid" dim={dim("grid")} value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Node id="house" dim={dim("house")} value={power(e.loadPowerW)} note="zużycie" />
<Node id="house" dim={dim("house")} value={power(e.housePowerW)} note="zużycie" />
<Node id="battery" dim={dim("battery")} onOpen={onBattery} value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
</svg>
</>
@@ -133,9 +133,9 @@ function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void })
// Today's use split by where it came from; the battery is charged from both, so "own" is what the grid did not give.
export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; link?: boolean }) {
const today = e?.today;
const load = today?.loadKwh ?? null;
const house = today?.houseKwh ?? null;
const bought = today?.gridBoughtKwh ?? null;
const own = load != null && bought != null ? Math.max(0, load - bought) : null;
const own = house != null && bought != null ? Math.max(0, house - bought) : null;
const parts = [
{ name: "Z PV i baterii", value: own ?? 0, colour: "#10b981" },
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
@@ -159,7 +159,7 @@ export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; li
</Pie>
</PieChart>
<div className="absolute inset-0 grid place-items-center text-center">
<div><p className="text-lg font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
<div><p className="text-lg font-bold tabular-nums">{kwh(house)}</p><p className="text-xs text-soft">zużycie</p></div>
</div>
</div>
<div className="min-w-44 flex-1 text-sm">
@@ -200,7 +200,8 @@ const unbreakable = (text: string) => text.replaceAll(" ", "\u00a0");
/** The main current values of a device, from the details and, for the house meter, from the flow. */
export function deviceSummary(d: EnergyDetailsDevice, e: EnergyData | undefined): string {
if (d.id === "deye") {
const pv = reading(d, "Panele PV").reduce((sum, item) => sum + item.value, 0);
// The PV group also lists each string's voltage and current: only the powers add up.
const pv = reading(d, "Panele PV").filter((item) => item.unit === "W").reduce((sum, item) => sum + item.value, 0);
return [`Bateria ${percent(reading(d, "Bateria", "Naładowanie")[0]?.value)}`, `PV ${power(pv)}`].map(unbreakable).join(" · ");
}
if (d.id === "fronius") return `PV ${power(reading(d, "Teraz", "Moc PV")[0]?.value)}`;
@@ -240,7 +241,7 @@ function Now({ onBattery }: { onBattery: () => void }) {
<EnergyToday e={e} />
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3 lg:grid-cols-4">
<Stat dim={quiet.includes("pv")} icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
<Stat dim={quiet.includes("house")} icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
<Stat dim={quiet.includes("house")} icon="home" label="Zużycie domu" value={power(e.housePowerW)} />
<Stat dim={quiet.includes("grid")} icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Stat dim={quiet.includes("battery")} icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
</div>
+37 -15
View File
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { Stats } from "./types";
import type { Program, Stats } from "./types";
// A fresh module per test: the poller keeps its answers between screens, not between tests.
const historyScreen = async () => (await import("./History")).History;
@@ -14,7 +14,7 @@ beforeEach(() => {
});
function serveStats(stats: Stats = example("stats")) {
return serve({ "api/stats": stats, "api/history": { series: "cwu_temp", unit: "°C", points: [] } });
return serve({ "api/stats": stats, "api/program": example("program"), "api/history": { series: "cwu_temp", unit: "°C", points: [] } });
}
async function open(stats?: Stats) {
@@ -30,7 +30,7 @@ describe("Historia views", () => {
serveStats();
const History = await historyScreen();
render(<History />);
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Wykresy", "Statystyki"]);
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Wykresy", "Statystyki", "Program"]);
expect(tab("Wykresy")).toHaveAttribute("aria-selected", "true");
await userEvent.click(tab("Statystyki"));
@@ -50,25 +50,14 @@ describe("Historia views", () => {
});
describe("Statystyki", () => {
it("shows the totals, the starts as a minimum and the heater highlighted when it ran", async () => {
it("shows the totals and the starts as a minimum", async () => {
await open();
expect(await screen.findByText("Załączenia sprężarki, co najmniej")).toBeInTheDocument();
expect(screen.getAllByText("0,2 h").length).toBeGreaterThan(0);
expect(screen.getByText("Grzałka").closest("[data-alert]")).toHaveAttribute("data-alert", "true");
expect(screen.getByText("Prąd z grzałki jest drogi")).toBeInTheDocument();
expect(screen.queryByRole("status")).toBeNull();
});
it("does not highlight a heater that stayed off", async () => {
const stats = example<Stats>("stats");
stats.totals.heaterHours = 0;
await open(stats);
await screen.findByText("Grzałka");
expect(screen.getByText("Grzałka").closest("[data-alert]")).toHaveAttribute("data-alert", "false");
});
it("asks for 30 days when chosen", async () => {
const fetch = await open();
await screen.findByText("Sprężarka");
@@ -94,4 +83,37 @@ describe("Statystyki", () => {
expect(await screen.findByText("Za mało danych w tym okresie.")).toBeInTheDocument();
});
it("shows the weekly program read from history, with how sure each day is", async () => {
serveStats();
location.hash = "history/program";
const History = await historyScreen();
render(<History />);
expect(tab("Program")).toHaveAttribute("aria-selected", "true");
const friday = (await screen.findByRole("rowheader", { name: "Pt" })).closest("tr")!;
expect(friday).toHaveTextContent("11:25–12:05");
expect(friday).toHaveTextContent("widziane raz");
expect(screen.getByRole("rowheader", { name: "Pn" }).closest("tr")).toHaveTextContent("brak danych");
expect(screen.getAllByRole("rowheader").map((h) => h.textContent)).toEqual(["Pn", "Wt", "Śr", "Cz", "Pt", "So", "Nd"]);
expect(screen.getByText(/Odczytane z historii pracy – sterownika nie odpytujemy o program \(wymagałoby to zapisów\)/))
.toBeInTheDocument();
});
it("marks agreeing days as confirmed and an observed day without comfort", async () => {
const program = example<Program>("program");
const circuit = { observedDays: 3, agreeingDays: 2 };
program.weekdays[0].cwu = { ...circuit, confidence: "confirmed", comfort: [{ from: "11:25", to: "12:05" }, { from: "17:55", to: "19:10" }] };
program.weekdays[1].cwu = { ...circuit, confidence: "seen_once", comfort: [] };
serve({ "api/program": program, "api/stats": example("stats"), "api/history": { series: "cwu_temp", unit: "°C", points: [] } });
location.hash = "history/program";
const History = await historyScreen();
render(<History />);
const monday = (await screen.findByRole("rowheader", { name: "Pn" })).closest("tr")!;
expect(monday).toHaveTextContent("11:25–12:05");
expect(monday).toHaveTextContent("17:55–19:10");
expect(monday).toHaveTextContent("potwierdzone");
expect(screen.getByRole("rowheader", { name: "Wt" }).closest("tr")).toHaveTextContent("bez komfortu");
});
});
+4 -3
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { number } from "./format";
import { usePoll } from "./poll";
import { Program } from "./Program";
import { Stats } from "./Stats";
import { RangeToggle, SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
import { TimeChart, toRows, type Range } from "./TimeChart";
@@ -18,7 +19,7 @@ const SERIES = {
pump_outlet_temp: ["Wyjście pompy ciepła", "#f26b1d"],
grid_power: ["Moc z sieci (+ pobór, − oddawanie)", "#3b82f6"],
pv_power: ["Produkcja PV", "#f59e0b"],
load_power: ["Zużycie domu", "#10b981"],
load_power: ["Wyjście zapasowe falownika", "#10b981"],
battery_soc: ["Bateria (naładowanie)", "#8b5cf6"],
} as const;
type SeriesId = keyof typeof SERIES;
@@ -66,7 +67,7 @@ function Charts() {
);
}
const VIEWS = { charts: "Wykresy", stats: "Statystyki" } as const;
const VIEWS = { charts: "Wykresy", stats: "Statystyki", program: "Program" } as const;
export function History() {
const [view, show] = useSubView("history", VIEWS);
@@ -75,7 +76,7 @@ export function History() {
<PageTitle title="Historia" subtitle="Dane z bazy pomiarów" />
<SubViewSwitch screen="history" label="Widok historii" views={VIEWS} view={view} onSelect={show} />
<SubViewPanel screen="history" view={view}>
{view === "charts" ? <Charts /> : <Stats />}
{view === "charts" ? <Charts /> : view === "stats" ? <Stats /> : <Program />}
</SubViewPanel>
</>
);
+31
View File
@@ -143,4 +143,35 @@ describe("Overview", () => {
expect(await screen.findByText(/Dane są nieaktualne/)).toBeInTheDocument();
});
it("names the period under the water target and in the room details", async () => {
const hp = fresh(example<HeatPump>("heat-pump"));
serveHeatPump({ ...hp, targets: { ...hp.targets, cwu: 43 }, periods: { ...hp.periods, cwu: "comfort" } });
const Overview = await overview();
render(<Overview />);
const water = (await screen.findByRole("region", { name: "Teraz" })).children[0];
expect(water).toHaveTextContent("cel 43,0 °Cstrefa komfortu");
const card = screen.getByRole("heading", { name: "Szczegóły temperatur" }).closest("section")!;
expect(card).toHaveTextContent("Pokój, strefastrefa oszczędna");
});
it("names the controller program instead of a period when it overrides the weekly one", async () => {
const hp = fresh(example<HeatPump>("heat-pump"));
const periods = { cwu: "override", room: "override", program: 2, programText: "Party" } as const;
serveHeatPump({ ...hp, periods });
const Overview = await overview();
render(<Overview />);
expect((await screen.findByRole("region", { name: "Teraz" })).children[0]).toHaveTextContent("program: Party");
});
it("says nothing about the period when it is unknown", async () => {
const hp = fresh(example<HeatPump>("heat-pump"));
serveHeatPump({ ...hp, periods: { cwu: "unknown", room: "unknown", program: 1, programText: null } });
const Overview = await overview();
render(<Overview />);
expect((await screen.findByRole("region", { name: "Teraz" })).children[0]).toHaveTextContent(/^Ciepła woda13,6 °Ccel 8,0 °C$/);
});
});
+13 -4
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { celsius, hour, longDate, number, percent, power } from "./format";
import { celsius, hour, longDate, number, percent, periodText, power } from "./format";
import { Icon, type IconName } from "./icons";
import { flowing } from "./flow";
import { usePoll } from "./poll";
@@ -58,13 +58,23 @@ const gridNote = (watts: number | null | undefined) =>
watts == null ? "brak danych o sieci"
: flowing(watts) ? `${gridText(watts)} ${power(Math.abs(watts))}` : gridText(watts);
function WaterTarget({ hp }: { hp: HeatPump }) {
const period = periodText(hp.periods.cwu, hp.periods.programText);
return (
<span className="flex flex-col">
<span>cel {celsius(hp.targets.cwu)}</span>
{period && <span>{period}</span>}
</span>
);
}
// The four figures the family checks first; each value is shown here and nowhere else on the screen.
function Now({ hp, e, pi, weather }: { hp: HeatPump; e: Energy | undefined; pi: PiSensor | undefined; weather: Weather | undefined }) {
const home = pi?.temperature ?? hp.temperatures.indoor;
const humidity = pi?.temperature == null ? null : pi.humidity;
return (
<section aria-label="Teraz" className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 lg:grid-cols-4">
<Figure icon="drop" label="Ciepła woda" value={celsius(hp.temperatures.cwu)} note={`cel ${celsius(hp.targets.cwu)}`} />
<Figure icon="drop" label="Ciepła woda" value={celsius(hp.temperatures.cwu)} note={<WaterTarget hp={hp} />} />
<Figure icon="home" label="W domu" value={celsius(home)}
note={humidity == null ? "czujnik pompy" : `wilgotność ${number(humidity)} %`} />
<Figure icon="thermometer" label="Na zewnątrz" value={celsius(hp.temperatures.outdoor)}
@@ -98,7 +108,7 @@ function Tile({ icon, title, detail, value, status }: {
function DeviceTiles({ hp }: { hp: HeatPump }) {
return (
<section aria-label="Stan urządzeń" className="grid gap-3 sm:grid-cols-2 sm:gap-4">
<Tile icon="fan" title="Pompa ciepła" detail={`sprężarka, grzałka ${onOff(hp.heaterOn, "włączona", "wyłączona")}`}
<Tile icon="fan" title="Pompa ciepła" detail="sprężarka"
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)} />
<Tile icon="tank" title="Zbiornik CWU" detail={`cyrkulacja ${onOff(hp.pumps.circulation, "pracuje", "stoi")}`}
@@ -115,7 +125,6 @@ function Devices({ hp }: { hp: HeatPump }) {
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
const items: [string, boolean | null, string][] = [
["Sprężarka", compressor, compressor ? percent(hp.compressorPercent) : "stoi"],
["Grzałka", hp.heaterOn, onOff(hp.heaterOn, "włączona", "wyłączona")],
...Object.entries(PUMP_NAMES).map(([key, name]): [string, boolean | null, string] => {
const on = hp.pumps[key as keyof typeof PUMP_NAMES];
return [name, on, onOff(on, "pracuje", "stoi")];
+56
View File
@@ -0,0 +1,56 @@
import { usePoll } from "./poll";
import type { Confidence, Program as ProgramData, ProgramCircuit } from "./types";
import { Card, Freshness, Loading } from "./ui";
const PROGRAM_MS = 5 * 60000; // the backend reads every raw heat pump row of the period for each request
const DAYS = ["Pn", "Wt", "Śr", "Cz", "Pt", "So", "Nd"]; // Monday is 1
const MARKER: Record<Confidence, string> = {
confirmed: "potwierdzone", seen_once: "widziane raz", no_data: "brak danych",
};
function Cell({ circuit }: { circuit: ProgramCircuit }) {
const { confidence, comfort, observedDays, agreeingDays } = circuit;
return (
<td className="px-2 py-2 align-top">
{confidence === "no_data" ? <span className="text-soft">—</span>
: comfort.length === 0 ? <span className="text-soft">bez komfortu</span>
: comfort.map((i) => <span key={i.from} className="block font-semibold tabular-nums">{i.from}–{i.to}</span>)}
<span className={`block text-xs ${confidence === "confirmed" ? "text-brand" : "text-soft"}`}
title={confidence === "no_data" ? undefined : `zgodne dni: ${agreeingDays} z ${observedDays}`}>
{MARKER[confidence]}
</span>
</td>
);
}
export function Program() {
const { data, offline } = usePoll<ProgramData>("api/program?days=14", PROGRAM_MS);
if (!data) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
return (
<Card title={`Strefy komfortu – ostatnie ${data.days} dni`}>
{offline && <Freshness offline />}
<table className="w-full text-left text-[0.95rem]">
<thead className="text-sm text-soft">
<tr>
<th className="px-2 py-1 font-semibold">Dzień</th>
<th className="px-2 py-1 font-semibold">Ciepła woda</th>
<th className="px-2 py-1 font-semibold">Pokój</th>
</tr>
</thead>
<tbody>
{data.weekdays.map((w) => (
<tr key={w.day} className="border-t border-edge">
<th scope="row" className="px-2 py-2 align-top font-semibold">{DAYS[w.day - 1]}</th>
<Cell circuit={w.cwu} />
<Cell circuit={w.room} />
</tr>
))}
</tbody>
</table>
<p className="mt-3 text-sm text-soft">
Odczytane z historii pracy – sterownika nie odpytujemy o program (wymagałoby to zapisów).
Czas lokalny; po zmianie czasu sterownik może przesunąć strefy o godzinę.
</p>
</Card>
);
}
-1
View File
@@ -115,7 +115,6 @@ export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | n
<Glow id="cwu" x={639} y={402} w={90} h={216} colour="#f97316" on={cwu} />
<Glow id="buffer" x={746} y={489} w={70} h={134} colour="#ef4444" on={buffer} />
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="var(--color-run)" />}
{hp.heaterOn && <rect className="glow" x={518} y={560} width={78} height={14} rx={7} fill="#ef4444" />}
{/* Warmth rising from the floor while the CO pump runs */}
{co && [960, 1080, 1200, 1300].map((x, i) => (
+125 -3
View File
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { answer, example, serve } from "./fixtures";
import { localInput } from "./format";
import type { Setpoints } from "./types";
import type { HeatPump, Setpoints } from "./types";
// A fresh module per test: the poller keeps its answers between screens, not between tests.
const settings = async () => (await import("./Settings")).Settings;
@@ -23,6 +23,38 @@ describe("Settings", () => {
expect(screen.getByText("Obniża temperatury na czas nieobecności, do dnia powrotu")).toBeInTheDocument();
});
describe("note under Ciepła woda teraz", () => {
const hp = (periods: Partial<HeatPump["periods"]>, cwuComfort: number) => {
const base = example<HeatPump>("heat-pump");
return { ...base, setpoints: { ...base.setpoints, cwuComfort }, periods: { ...base.periods, ...periods } };
};
const show = async (pump: HeatPump) => {
serve({ "api/setpoints": example("setpoints"), "api/heat-pump": pump });
const Settings = await settings();
render(<Settings />);
await screen.findByText("Ciepła woda – oszczędnie: 45 °C na 2 godz.");
};
it("says the water heats to the comfort value anyway when comfort is not below the preset", async () => {
await show(hp({ cwu: "comfort" }, 45));
expect(await screen.findByText("Teraz strefa komfortu – woda i tak grzeje się do 45 °C")).toBeInTheDocument();
});
it("says the preset still raises economy for later when comfort is below it", async () => {
await show(hp({ cwu: "comfort" }, 43));
expect(await screen.findByText(/Teraz strefa komfortu \(43 °C\)\. Ustawienie podnosi też temperaturę oszczędną do 45 °C/))
.toBeInTheDocument();
});
it("stays silent outside the comfort period", async () => {
await show(hp({ cwu: "economy" }, 45));
expect(screen.queryByText(/Teraz strefa komfortu/)).toBeNull();
});
});
it("writes only after the summary is confirmed", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-applied")));
const Settings = await settings();
@@ -178,8 +210,10 @@ describe("Settings", () => {
expect(posts(fetch)).toHaveLength(0);
});
it("keeps the plain confirmation for a preset without a chosen end", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
it("keeps the plain confirmation for a preset that is neither held nor timed", async () => {
const data = example<Setpoints>("setpoints");
data.presets[0].changes[0].hours = null;
const fetch = serve({ "api/setpoints": data }, () => answer(example("setpoints-preset-applied")));
const Settings = await settings();
render(<Settings />);
@@ -191,6 +225,94 @@ describe("Settings", () => {
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({});
});
describe("form of Ciepła woda teraz", () => {
const open = async (data = example<Setpoints>("setpoints"), pump?: HeatPump) => {
const fetch = serve({ "api/setpoints": data, ...(pump && { "api/heat-pump": pump }) },
() => answer(example("setpoints-preset-applied")));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Ciepła woda teraz" }));
return fetch;
};
const more = (times: number) => Array.from({ length: times }).reduce<Promise<void>>(
(done) => done.then(() => userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – oszczędnie"))), Promise.resolve());
it("starts at the preset's temperature and hours and sends them", async () => {
const fetch = await open();
expect(screen.getByRole("status").textContent).toBe("45 °C");
expect(screen.getByRole("button", { name: "2 h" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByText(paragraph("Ciepła woda do 45 °C przez 2 godz., potem wróci 8 °C."))).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Podgrzej wodę" }));
expect(posts(fetch)[0][0]).toBe("api/presets/cwu-now");
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({ hours: 2, values: { cwu_economy: 45 } });
});
it("sends the temperature and hours the user chose", async () => {
const fetch = await open();
await userEvent.click(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie"));
await userEvent.click(screen.getByRole("button", { name: "4 h" }));
expect(screen.getByRole("button", { name: "2 h" })).toHaveAttribute("aria-pressed", "false");
expect(screen.getByText(paragraph("Ciepła woda do 44 °C przez 4 godz., potem wróci 8 °C."))).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Podgrzej wodę" }));
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({ hours: 4, values: { cwu_economy: 44 } });
});
it("stays between just above the current value and the gateway's maximum", async () => {
const data = example<Setpoints>("setpoints");
data.setpoints.find((s) => s.name === "cwu_economy")!.value = 44;
data.presets[0].changes[0].value = 40;
await open(data);
expect(screen.getByRole("status").textContent).toBe("45 °C");
expect(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie")).toBeDisabled();
await more(5);
expect(screen.getByRole("status").textContent).toBe("48 °C");
expect(screen.getByLabelText("Więcej: Ciepła woda – oszczędnie")).toBeDisabled();
});
it("cannot be confirmed when the water is already at the maximum", async () => {
const data = example<Setpoints>("setpoints");
data.setpoints.find((s) => s.name === "cwu_economy")!.value = 48;
await open(data);
expect(screen.getByText("Już na maksimum (48 °C), nie da się podnieść.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Podgrzej wodę" })).toBeDisabled();
});
it("warns above 47 °C that the pump alone does not get there", async () => {
await open();
const hint = "Pompa sama dochodzi do ok. 47–48 °C (wyjście wyłącza się przy 52 °C)";
await more(2);
expect(screen.queryByText(hint)).toBeNull();
await more(1);
expect(screen.getByText(hint)).toBeInTheDocument();
});
it("keeps the comfort-period note, for the chosen temperature", async () => {
const pump = example<HeatPump>("heat-pump");
await open(undefined, { ...pump, setpoints: { ...pump.setpoints, cwuComfort: 46 }, periods: { ...pump.periods, cwu: "comfort" } });
expect(await screen.findAllByText("Teraz strefa komfortu – woda i tak grzeje się do 46 °C")).toHaveLength(1);
await more(2);
expect(screen.getByText(/Teraz strefa komfortu \(46 °C\)\. Ustawienie podnosi też temperaturę oszczędną do 47 °C/))
.toBeInTheDocument();
});
it("closes on cancel without writing", async () => {
const fetch = await open();
await userEvent.click(screen.getByRole("button", { name: "Anuluj" }));
expect(screen.getByRole("button", { name: "Ciepła woda teraz" })).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(0);
});
});
it("shows a held change with its end date and the value that comes back", async () => {
const data = example<Setpoints>("setpoints");
data.restores = [{ setpoint: "cwu_comfort", register: 4144, originalValue: 43, appliedValue: 10, due: "2030-11-02T17:00:00Z" }];
+111 -26
View File
@@ -1,9 +1,10 @@
import { useEffect, useState, type ReactNode } from "react";
import { postJson, WriteError } from "./api";
import { Accounts } from "./Accounts";
import { ChangeHistory } from "./ChangeHistory";
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
import { reload, usePoll } from "./poll";
import type { Change, CommandResult, LineStatus, Preset, RestoreOutcome, RestoreResult, Setpoint, Setpoints } from "./types";
import type { Change, CommandResult, HeatPump, LineStatus, Preset, RestoreOutcome, RestoreResult, Setpoint, Setpoints } from "./types";
import { Button, Card, Freshness, Loading, PageTitle, Stepper } from "./ui";
const URL = "api/setpoints";
@@ -84,6 +85,46 @@ function ResultBox({ result }: { result: Result }) {
return <div role={ok ? "status" : "alert"} className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${tone}`}>{body}</div>;
}
type Limits = Record<string, { min: number; max: number; step: number }>;
const limitsOf = (data: Setpoints, preset: Preset, range: (s: Setpoint) => { min: number; max: number }): Limits =>
Object.fromEntries(preset.changes.map((c) => {
const s = find(data, c.setpoint);
return [c.setpoint, { ...range(s), step: s.step }];
}));
// The preset's own values, moved into the limits; the user steps them from there.
function usePresetValues(preset: Preset, limits: Limits) {
const [values, setValues] = useState(() => Object.fromEntries(preset.changes.map((c) => {
const { min, max } = limits[c.setpoint];
return [c.setpoint, Math.max(min, Math.min(max, c.value))];
})));
const stepValue = (id: string, direction: number) => {
const { min, max, step } = limits[id];
setValues({ ...values, [id]: Math.max(min, Math.min(max, values[id] + direction * step)) });
};
const stuck = preset.changes.some((c) => limits[c.setpoint].max < limits[c.setpoint].min);
return { values, stepValue, stuck };
}
function ValueSteppers({ data, preset, limits, values, busy, stuckNote, onStep }: {
data: Setpoints; preset: Preset; limits: Limits; values: Record<string, number>; busy: boolean;
stuckNote: (current: string) => string; onStep: (id: string, direction: number) => void;
}) {
return preset.changes.map((c) => {
const { min, max } = limits[c.setpoint];
const label = name(c.setpoint);
return (
<div key={c.setpoint} className="my-3">
<span className="block font-semibold">{label}</span>
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} format={degrees} disabled={busy || max < min}
onStep={(direction) => onStep(c.setpoint, direction)} />
{max < min && <p className="text-sm text-alarm">{stuckNote(degrees(find(data, c.setpoint).value))}</p>}
</div>
);
});
}
const DAY_MS = 24 * 3600_000;
// Away is held by the gateway until this moment, at most 30 days ahead (ADR-012).
@@ -94,19 +135,8 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
data: Setpoints; preset: Preset; busy: boolean; onCancel: () => void;
onConfirm: (until: string, values: Record<string, number>) => void;
}) {
const limits = Object.fromEntries(preset.changes.map((c) => {
const s = find(data, c.setpoint);
return [c.setpoint, { min: s.min, max: lowestAllowed(s), step: s.step }];
}));
const [values, setValues] = useState(() => Object.fromEntries(preset.changes.map((c) => {
const { min, max } = limits[c.setpoint];
return [c.setpoint, Math.max(min, Math.min(max, c.value))];
})));
const stuck = preset.changes.some((c) => limits[c.setpoint].max < limits[c.setpoint].min);
const stepValue = (id: string, direction: number) => {
const { min, max, step } = limits[id];
setValues({ ...values, [id]: Math.max(min, Math.min(max, values[id] + direction * step)) });
};
const limits = limitsOf(data, preset, (s) => ({ min: s.min, max: lowestAllowed(s) }));
const { values, stepValue, stuck } = usePresetValues(preset, limits);
const [now] = useState(() => Date.now());
const min = localInput(new Date(now + 3600_000));
const max = localInput(new Date(now + 30 * DAY_MS));
@@ -124,18 +154,8 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
<input type="datetime-local" value={until} min={min} max={max} onChange={(e) => setUntil(e.target.value)}
className="mt-1 block min-h-12 w-full rounded-xl border-2 border-edge bg-panel px-3 text-lg" />
</label>
{preset.changes.map((c) => {
const { min, max } = limits[c.setpoint];
const label = name(c.setpoint);
return (
<div key={c.setpoint} className="my-3">
<span className="block font-semibold">{label}</span>
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} format={degrees} disabled={busy || max < min}
onStep={(direction) => stepValue(c.setpoint, direction)} />
{max < min && <p className="text-sm text-alarm">Już na minimum ({degrees(find(data, c.setpoint).value)}), nie da się obniżyć.</p>}
</div>
);
})}
<ValueSteppers data={data} preset={preset} limits={limits} values={values} busy={busy} onStep={stepValue}
stuckNote={(current) => `Już na minimum (${current}), nie da się obniżyć.`} />
<div className="flex gap-3">
<Button onClick={onCancel} disabled={busy}>Anuluj</Button>
<Button primary onClick={() => onConfirm(withOffset(until), values)} disabled={busy || !valid}>Ustaw wyjazd</Button>
@@ -144,11 +164,69 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
);
}
// What the pump reaches on its own in the tank; above it only the CWU output (off at 52 °C) helps.
const PUMP_ALONE_MAX = 47;
const PRESET_HOURS = [1, 2, 3, 4];
// A timed preset raises: from just above the current value up to the gateway's maximum (none left: min > max).
const raisable = (s: Setpoint) => ({ min: Math.max(s.min, s.value! + s.step), max: s.max });
const isCwu = (setpoint: string) => setpoint.startsWith("cwu_");
function TimedForm({ data, preset, hp, busy, onCancel, onConfirm }: {
data: Setpoints; preset: Preset; hp: HeatPump | undefined; busy: boolean; onCancel: () => void;
onConfirm: (hours: number, values: Record<string, number>) => void;
}) {
const limits = limitsOf(data, preset, raisable);
const { values, stepValue, stuck } = usePresetValues(preset, limits);
const presetHours = preset.changes[0].hours!;
const [hours, setHours] = useState(Math.min(4, Math.max(1, Math.round(presetHours))));
const chosen = preset.changes.map((c) => ({ ...c, value: values[c.setpoint] }));
const above = chosen.some((c) => isCwu(c.setpoint) && c.value > PUMP_ALONE_MAX);
return (
<>
{chosen.map((c) => (
<p key={c.setpoint} className="my-2 text-lg font-semibold">
{isCwu(c.setpoint) ? "Ciepła woda" : name(c.setpoint)} do <b>{degrees(c.value)}</b> przez {duration(hours)}, potem wróci {degrees(returnValue(data, c.setpoint))}.
</p>
))}
<ValueSteppers data={data} preset={preset} limits={limits} values={values} busy={busy} onStep={stepValue}
stuckNote={(current) => `Już na maksimum (${current}), nie da się podnieść.`} />
{above && <p className="text-sm text-soft">Pompa sama dochodzi do ok. 47–48 °C (wyjście wyłącza się przy 52 °C)</p>}
<ComfortNote changes={chosen} hp={hp} />
<p className="mt-4 mb-2 font-semibold">Na jak długo</p>
<div className="mb-4 grid grid-cols-4 gap-2">
{PRESET_HOURS.map((h) => (
<Button key={h} aria-pressed={h === hours} disabled={busy} onClick={() => setHours(h)}>{h} h</Button>
))}
</div>
<div className="flex gap-3">
<Button onClick={onCancel} disabled={busy}>Anuluj</Button>
<Button primary onClick={() => onConfirm(hours, values)} disabled={busy || stuck}>Podgrzej wodę</Button>
</div>
</>
);
}
// What a preset does, in one line under its button; a held preset has no fixed values (the user picks them).
const presetHint = (p: Preset) => p.until
? "Obniża temperatury na czas nieobecności, do dnia powrotu"
: p.changes.map((c) => `${name(c.setpoint)}: ${degrees(c.value)}${c.hours == null ? " do odwołania" : ` na ${duration(c.hours)}`}`).join(", ");
// The controller works to the comfort value in a comfort period, whatever economy is set to.
function ComfortNote({ changes, hp }: { changes: Change[]; hp: HeatPump | undefined }) {
const raised = changes.find((c) => c.setpoint === "cwu_economy")?.value;
const comfort = hp?.setpoints.cwuComfort;
if (raised == null || comfort == null || hp?.stale || hp?.periods.cwu !== "comfort") return null;
return (
<p className="text-center text-sm font-semibold text-brand">
{comfort >= raised
? `Teraz strefa komfortu – woda i tak grzeje się do ${degrees(comfort)}`
: `Teraz strefa komfortu (${degrees(comfort)}). Ustawienie podnosi też temperaturę oszczędną do ${degrees(raised)}, więc woda będzie grzana do tej wartości także po zakończeniu strefy.`}
</p>
);
}
function changeSentence(data: Setpoints, c: Change, held: boolean) {
const back = degrees(returnValue(data, c.setpoint));
const tail = held ? `do powrotu, potem wróci ${back}`
@@ -158,6 +236,7 @@ function changeSentence(data: Setpoints, c: Change, held: boolean) {
export function Settings() {
const { data, offline } = usePoll<Setpoints>(URL);
const { data: hp } = usePoll<HeatPump>("api/heat-pump");
// The one setpoint being changed; confirming: the summary is shown, waiting for "Tak".
const [draft, setDraft] = useState<{ name: string; value: number } | null>(null);
const [hours, setHours] = useState<number | null>(2);
@@ -228,6 +307,7 @@ export function Settings() {
const preset = data.presets.find((p) => p.id === presetId);
if (preset) {
const timed = preset.changes.every((c) => c.hours != null);
return (
<>
{title}
@@ -236,6 +316,9 @@ export function Settings() {
{preset.until ? (
<ReturnForm data={data} preset={preset} busy={busy} onCancel={cancel}
onConfirm={(until, values) => send(`api/presets/${preset.id}`, { until, values }, preset.changes)} />
) : timed ? (
<TimedForm data={data} preset={preset} hp={hp} busy={busy} onCancel={cancel}
onConfirm={(hours, values) => send(`api/presets/${preset.id}`, { hours, values }, preset.changes)} />
) : (
<>
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c, false)}</p>)}
@@ -294,6 +377,7 @@ export function Settings() {
{p.name}{ready ? "" : " (brak odczytu)"}
</Button>
<p className="text-center text-sm text-soft">{presetHint(p)}</p>
<ComfortNote changes={p.changes} hp={hp} />
</div>
);
})}
@@ -343,6 +427,7 @@ export function Settings() {
</Card>
</div>
<ChangeHistory />
<Accounts />
</>
);
}
+3 -8
View File
@@ -28,14 +28,11 @@ export function takting(days: StatsDay[]) {
return days.some((d) => (d.compressorStarts ?? 0) > TAKTING_STARTS_PER_DAY);
}
function Tile({ label, value, hint, alert = false, wide = false }: {
label: string; value: string; hint?: string; alert?: boolean; wide?: boolean;
}) {
function Tile({ label, value }: { label: string; value: string }) {
return (
<div data-alert={alert} className={`rounded-xl border p-3 ${wide ? "col-span-2 lg:col-span-1" : ""} ${alert ? "border-alarm bg-run-soft" : "border-edge bg-page"}`}>
<div className="rounded-xl border border-edge bg-page p-3">
<p className="text-2xl font-bold tabular-nums">{value}</p>
<p className="text-sm text-soft">{label}</p>
{hint && <p className="text-sm text-soft">{hint}</p>}
</div>
);
}
@@ -43,13 +40,11 @@ function Tile({ label, value, hint, alert = false, wide = false }: {
function Totals({ stats }: { stats: StatsData }) {
const t = stats.totals;
return (
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-5">
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
<Tile label="Sprężarka" value={hours(t.compressorHours)} />
<Tile label="Załączenia sprężarki, co najmniej" value={number(t.compressorStarts)} />
<Tile label="Ciepła woda (CWU)" value={hours(t.cwuHours)} />
<Tile label="Ogrzewanie (CO)" value={hours(t.coHours)} />
<Tile label="Grzałka" value={hours(t.heaterHours)} wide alert={t.heaterHours > 0}
hint={t.heaterHours > 0 ? "Prąd z grzałki jest drogi" : undefined} />
</div>
);
}
+2
View File
@@ -1,3 +1,4 @@
import { Account } from "./Auth";
import { ago } from "./format";
import { Icon } from "./icons";
import { usePoll } from "./poll";
@@ -43,6 +44,7 @@ export function Status() {
);
})}
</Card>
<Account card />
<Card title="Wersje">
<Row label="Aplikacja" value={__APP_VERSION__} />
<Row label="Serwer" value={data.backendVersion} />
+38 -7
View File
@@ -4,12 +4,44 @@ import type { CommandResult, RefreshAnswer } from "./types";
const READ_TIMEOUT_MS = 15000;
const WRITE_TIMEOUT_MS = 20000;
// The custom header is what the backend uses to tell this page from another site's form.
const POST_HEADERS = { "Content-Type": "application/json", "X-Requested-With": "home-pump" };
export class HttpError extends Error {
constructor(url: string, readonly status: number) {
super(`${url}: ${status}`);
}
}
// Every call below reports a 401 here, so the app can go back to the login screen when the session ends.
let onSessionExpired: () => void = () => undefined;
export const whenSessionExpires = (listener: () => void) => {
onSessionExpired = listener;
};
const checked = (response: Response) => {
if (response.status === 401) onSessionExpired();
return response;
};
export async function getJson<T>(url: string): Promise<T> {
const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(READ_TIMEOUT_MS) });
if (!response.ok) throw new Error(`${url}: ${response.status}`);
const response = checked(await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(READ_TIMEOUT_MS) }));
if (!response.ok) throw new HttpError(url, response.status);
return response.json();
}
// The login calls: the HTTP status and the JSON body (null when there is none), status 0 when the network fails.
// A 401 here is a wrong password, not an expired session.
export async function postAuth(url: string, body?: unknown): Promise<{ status: number; body: any }> {
try {
const response = await fetch(url, {
method: "POST", headers: POST_HEADERS, body: JSON.stringify(body ?? {}), signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
});
return { status: response.status, body: await response.json().catch(() => null) };
} catch {
return { status: 0, body: null };
}
}
export class WriteError extends Error {
constructor(readonly status: number) {
super(`write failed: ${status}`);
@@ -24,11 +56,10 @@ export async function postJson<T extends { status?: string; outcome?: string } =
): Promise<T> {
const response = await fetch(url, {
method: "POST",
// The custom header is what the backend uses to tell this page from another site's form.
headers: { "Content-Type": "application/json", "X-Requested-With": "home-pump" },
headers: POST_HEADERS,
body: JSON.stringify(body ?? {}),
signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
}).catch(() => {
}).then(checked, () => {
throw new WriteError(0);
});
const answer = await response.json().catch(() => null);
@@ -40,10 +71,10 @@ export async function postJson<T extends { status?: string; outcome?: string } =
export async function postRefresh(): Promise<RefreshAnswer> {
const response = await fetch("api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Requested-With": "home-pump" },
headers: POST_HEADERS,
body: "{}",
signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
}).catch(() => {
}).then(checked, () => {
throw new WriteError(0);
});
if (!response.ok) throw new WriteError(response.status);
+10
View File
@@ -1,4 +1,6 @@
// Polish number and time formats; every function accepts null and shows a dash.
import type { Period } from "./types";
export const DASH = "—";
type Value = number | null | undefined;
@@ -94,3 +96,11 @@ export function withOffset(local: string): string {
export function duration(hours: number): string {
return hours < 1 ? `${Math.round(hours * 60)} min` : `${number(hours, 1).replace(/,0$/, "")} godz.`;
}
/** The period a circuit is in, in words; null when it is not known. */
export function periodText(period: Period, programText: string | null): string | null {
if (period === "comfort") return "strefa komfortu";
if (period === "economy") return "strefa oszczędna";
if (period === "override") return programText ? `program: ${programText}` : "program inny niż tygodniowy";
return null;
}
+2 -1
View File
@@ -1,12 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { AuthGate } from "./Auth";
import "./index.css";
import { applyTheme, storedTheme } from "./theme";
applyTheme(storedTheme()); // before the first paint, so a dark choice does not flash white
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
createRoot(document.getElementById("root")!).render(<StrictMode><AuthGate><App /></AuthGate></StrictMode>);
// Offline shell (public/manifest.webmanifest, src/sw.js); not in dev, where it would serve stale modules.
if (import.meta.env.PROD && "serviceWorker" in navigator) {
+27 -5
View File
@@ -2,12 +2,20 @@
export type PumpState = "IDLE" | "HEATING_CWU" | "HEATING_CO" | "HEATING_BOTH";
export type Period = "comfort" | "economy" | "unknown" | "override";
export interface Periods {
cwu: Period;
room: Period;
program: number | null;
programText: string | null; // set when the controller runs a program other than the weekly one
}
export interface HeatPump {
time: string | null;
stale: boolean;
state: PumpState | null;
compressorPercent: number | null;
heaterOn: boolean | null;
alarm: boolean;
alarmCode: number | null;
alarmText: string | null;
@@ -18,6 +26,7 @@ export interface HeatPump {
>;
targets: Record<"room" | "co" | "cwu", number | null>;
setpoints: Record<"coEconomy" | "coComfort" | "cwuEconomy" | "cwuComfort", number | null>;
periods: Periods;
}
export interface PiSensor {
@@ -41,8 +50,9 @@ export interface Energy {
gridPowerW: number | null;
batteryPowerW: number | null;
batterySocPct: number | null;
loadPowerW: number | null;
today: { pvKwh: number | null; gridBoughtKwh: number | null; gridSoldKwh: number | null; loadKwh: number | null };
housePowerW: number | null;
backupOutputW: number | null;
today: { pvKwh: number | null; gridBoughtKwh: number | null; gridSoldKwh: number | null; houseKwh: number | null };
}
export interface BatteryEnergy {
@@ -250,7 +260,6 @@ export interface StatsDay {
compressorStarts: number | null;
cwuHours: number | null;
coHours: number | null;
heaterHours: number | null;
avgOutdoorTemp: number | null;
minCwuTemp: number | null;
maxCwuTemp: number | null;
@@ -261,7 +270,6 @@ export interface StatsTotals {
compressorStarts: number;
cwuHours: number;
coHours: number;
heaterHours: number;
avgOutdoorTemp: number | null;
minCwuTemp: number | null;
maxCwuTemp: number | null;
@@ -272,3 +280,17 @@ export interface Stats {
days: StatsDay[];
totals: StatsTotals;
}
export type Confidence = "confirmed" | "seen_once" | "no_data";
export interface ProgramCircuit {
confidence: Confidence;
observedDays: number;
agreeingDays: number;
comfort: { from: string; to: string }[];
}
export interface Program {
days: number;
weekdays: { day: number; cwu: ProgramCircuit; room: ProgramCircuit }[]; // Monday is 1
}
+3 -1
View File
@@ -40,5 +40,7 @@ export default defineConfig({
},
// `npm run dev` asks a running backend for /api: BACKEND, by default the backend's own default port.
server: { proxy: { "/api": process.env.BACKEND ?? "http://localhost:8080" } },
test: { environment: "jsdom", setupFiles: ["src/test-setup.ts"] },
// CI builds the gateway and backend at the same time (scripts/check.sh), so a test's first render can
// take several seconds there; the default 5 s timed out and left its DOM for the next test.
test: { environment: "jsdom", setupFiles: ["src/test-setup.ts"], testTimeout: 20000 },
});