Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f0e8922a7 | ||
|
|
326db85114 | ||
|
|
ead74ebdec | ||
|
|
f99ae01626 | ||
|
|
a804923290 | ||
|
|
cc0495fc46 | ||
|
|
b3fb7f8b40 | ||
|
|
33aead7352 | ||
|
|
ee13c00bbb | ||
|
|
2e94cc1429 | ||
|
|
0895125e9c | ||
|
|
36bee5ca08 | ||
|
|
fa678473e3 | ||
|
|
b46d820fb2 | ||
|
|
f61c25f449 | ||
|
|
eec29666eb | ||
|
|
6d9e92ee22 | ||
|
|
bb22afcd35 | ||
|
|
ae9376b24d | ||
|
|
31b668f313 | ||
|
|
36875dd103 | ||
|
|
c8ada0e6a8 | ||
|
|
0e3a77047b | ||
|
|
93fedb198c | ||
|
|
b69beadd76 | ||
|
|
62d61af5dd | ||
|
|
4e8ffc2ec3 | ||
|
|
eac21b17e2 | ||
|
|
73bbb1a0d7 | ||
|
|
f92307d9e1 | ||
|
|
34b12881b9 | ||
|
|
b7b26d83b2 | ||
|
|
24b7946df1 | ||
|
|
16534ddcee | ||
|
|
e6d9487f0c | ||
|
|
64ffd035c5 | ||
|
|
87d111398c | ||
|
|
24a141a377 | ||
|
|
a1d6a5c8a6 | ||
|
|
d1f053079a | ||
|
|
8b07b9ba0e | ||
|
|
aaf08d4f5c | ||
|
|
b0c0872775 | ||
|
|
f128b03ae5 | ||
|
|
9f9e4e4f50 | ||
|
|
4b26028d01 |
No files matched your search
@@ -24,7 +24,8 @@ Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/
|
|||||||
then `git push origin master` and delete the branch locally and on origin. Confirm CI is
|
then `git push origin master` and delete the branch locally and on origin. Confirm CI is
|
||||||
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
|
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
|
||||||
6. Release and update only with the user's explicit approval for this one:
|
6. Release and update only with the user's explicit approval for this one:
|
||||||
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) tags master;
|
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) waits for green
|
||||||
|
CI on master's head (no local check run), then tags master;
|
||||||
wait until the Release run under `…/actions` is green (it publishes the images). Then on the
|
wait until the Release run under `…/actions` is green (it publishes the images). Then on the
|
||||||
Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`,
|
Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`,
|
||||||
watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and
|
watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# One job: check.sh builds the three parts in parallel itself. The runner runs one job at a time
|
||||||
|
# and its actions cache server is unreachable from job containers (ETIMEDOUT), so separate jobs
|
||||||
|
# or setup-java/setup-node caches would only add waiting.
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|||||||
@@ -13,8 +13,20 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
images:
|
images:
|
||||||
name: Images
|
name: Image (${{ matrix.part }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into
|
||||||
|
# the final stage, so the arm64 image needs no emulation.
|
||||||
|
- part: gateway
|
||||||
|
platforms: linux/arm64,linux/amd64
|
||||||
|
- part: backend
|
||||||
|
platforms: linux/amd64
|
||||||
|
- part: frontend
|
||||||
|
platforms: linux/amd64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -31,29 +43,14 @@ jobs:
|
|||||||
username: ${{ gitea.repository_owner }}
|
username: ${{ gitea.repository_owner }}
|
||||||
# The owner's token with package write access (the secret homelab-upgrade uses too).
|
# The owner's token with package write access (the secret homelab-upgrade uses too).
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into the
|
# The registry cache keeps the Maven and npm layers between releases.
|
||||||
# final stage, so the arm64 image needs no emulation.
|
- name: Build and push
|
||||||
- name: Gateway
|
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: gateway
|
context: ${{ matrix.part }}
|
||||||
build-args: VERSION=${{ gitea.ref_name }}
|
build-args: VERSION=${{ gitea.ref_name }}
|
||||||
platforms: linux/arm64,linux/amd64
|
platforms: ${{ matrix.platforms }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
|
tags: ${{ env.IMAGES }}-${{ matrix.part }}:${{ gitea.ref_name }},${{ env.IMAGES }}-${{ matrix.part }}:latest
|
||||||
- name: Backend
|
cache-from: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache
|
||||||
uses: docker/build-push-action@v6
|
cache-to: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache,mode=max
|
||||||
with:
|
|
||||||
context: backend
|
|
||||||
build-args: VERSION=${{ gitea.ref_name }}
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: true
|
|
||||||
tags: ${{ env.IMAGES }}-backend:${{ gitea.ref_name }},${{ env.IMAGES }}-backend:latest
|
|
||||||
- name: Frontend
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: frontend
|
|
||||||
build-args: VERSION=${{ gitea.ref_name }}
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: true
|
|
||||||
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
|
|
||||||
+3
-3
@@ -15,8 +15,8 @@ USER 10001:10001
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body),
|
# /api/live needs no session and does not look at the gateway, so it only tells whether the backend
|
||||||
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine.
|
# itself serves requests. busybox wget ships with alpine.
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
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"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
+80
-9
@@ -3,6 +3,7 @@
|
|||||||
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
|
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).
|
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.
|
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/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`:
|
`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)
|
`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
|
`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`,
|
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.
|
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`,
|
`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).
|
`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`):
|
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
|
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
|
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}`.
|
`{"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
|
`{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).
|
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
|
`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;
|
(`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`, `%`,
|
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
|
`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`,
|
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
|
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||||
without the two URLs.
|
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 |
|
| Variable | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -55,32 +63,95 @@ 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 |
|
| `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_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 |
|
| `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 |
|
| `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
|
```bash
|
||||||
mvn -f backend/pom.xml clean package
|
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/
|
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
|
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")
|
`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`):
|
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"`
|
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;
|
(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
|
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).
|
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
|
||||||
|
extra poll of every device and passes its answer through, example `src/test/resources/api/refresh.json`
|
||||||
|
(`requested`, and `skipped` with `retryInSeconds` for devices polled within the last minute). Read-only; 503 when
|
||||||
|
`GATEWAY_TOKEN` is not set or the gateway is unreachable.
|
||||||
|
|
||||||
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
|
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
|
||||||
pending change of that setpoint now and answers with the gateway's verdict, example
|
pending change of that setpoint now and answers with the gateway's verdict, example
|
||||||
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
|
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
|
||||||
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
|
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
|
||||||
pending), 503 gateway unreachable.
|
pending), 503 gateway unreachable.
|
||||||
|
|
||||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
|
Publish no host port in production: only the frontend, behind Traefik, may reach the backend, so Traefik's
|
||||||
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
|
access rules (ADR-009) stay in front of the sign-in.
|
||||||
rules (ADR-009).
|
|
||||||
|
|
||||||
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
|
`/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).
|
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
|
||||||
@@ -9,6 +9,13 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
TZ: Europe/Warsaw
|
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: [home-pump]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package home.backend;
|
package home.backend;
|
||||||
|
|
||||||
|
import home.backend.adapters.auth.Authentication;
|
||||||
import home.backend.adapters.config.PresetFile;
|
import home.backend.adapters.config.PresetFile;
|
||||||
import home.backend.adapters.gateway.GatewayHttpClient;
|
import home.backend.adapters.gateway.GatewayHttpClient;
|
||||||
import home.backend.adapters.gateway.GatewayHttpCommands;
|
import home.backend.adapters.gateway.GatewayHttpCommands;
|
||||||
@@ -18,6 +19,7 @@ import home.backend.application.EnergyService;
|
|||||||
import home.backend.application.HealthService;
|
import home.backend.application.HealthService;
|
||||||
import home.backend.application.HeatPumpService;
|
import home.backend.application.HeatPumpService;
|
||||||
import home.backend.application.HistoryService;
|
import home.backend.application.HistoryService;
|
||||||
|
import home.backend.application.ProgramService;
|
||||||
import home.backend.application.StatsService;
|
import home.backend.application.StatsService;
|
||||||
import home.backend.application.LeaseRenewer;
|
import home.backend.application.LeaseRenewer;
|
||||||
import home.backend.application.PiSensorService;
|
import home.backend.application.PiSensorService;
|
||||||
@@ -43,7 +45,7 @@ final class Backend {
|
|||||||
/**
|
/**
|
||||||
* Binds the port but does not start serving.
|
* 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 {
|
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
|
||||||
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
|
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 HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
|
||||||
new BatteryService(gateway, commands, clock),
|
new BatteryService(gateway, commands, clock),
|
||||||
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
|
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
|
||||||
weather(influxSettings, clock));
|
new ProgramService(influx, clock),
|
||||||
|
weather(influxSettings, clock), commands,
|
||||||
|
Authentication.create(settings.auth(), clock));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WeatherService weather(InfluxSettings influx, Clock 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.http.ApiServer;
|
||||||
|
|
||||||
|
import home.backend.adapters.auth.PasswordHashCli;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
|
||||||
@@ -11,6 +13,9 @@ public final class Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void main(String[] args) throws IOException {
|
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;
|
Settings settings;
|
||||||
ApiServer server;
|
ApiServer server;
|
||||||
try {
|
try {
|
||||||
@@ -21,6 +26,7 @@ public final class Main {
|
|||||||
System.exit(2);
|
System.exit(2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
settings.auth().warnings().forEach(w -> System.err.println("home-pump-backend: WARNING: " + w));
|
||||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||||
if (settings.gatewayToken() == null) {
|
if (settings.gatewayToken() == null) {
|
||||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package home.backend;
|
package home.backend;
|
||||||
|
|
||||||
|
import home.backend.adapters.auth.AuthSettings;
|
||||||
|
import home.backend.adapters.auth.OidcSettings;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -8,7 +12,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
/** Twelve-factor settings: everything comes from environment variables. */
|
/** Twelve-factor settings: everything comes from environment variables. */
|
||||||
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
|
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) {
|
public static Settings fromEnvironment(Map<String, String> env) {
|
||||||
return new Settings(
|
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, "GATEWAY_TIMEOUT", 5, 1, 300)),
|
||||||
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
|
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
|
||||||
optional(env, "GATEWAY_TOKEN"),
|
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. */
|
/** 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,13 @@ import com.fasterxml.jackson.databind.SerializationFeature;
|
|||||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
import home.backend.application.GatewayBattery;
|
import home.backend.application.GatewayBattery;
|
||||||
import home.backend.application.GatewayCommands;
|
import home.backend.application.GatewayCommands;
|
||||||
|
import home.backend.application.GatewayRefresh;
|
||||||
import home.backend.domain.BatteryChange;
|
import home.backend.domain.BatteryChange;
|
||||||
import home.backend.domain.BatteryResult;
|
import home.backend.domain.BatteryResult;
|
||||||
import home.backend.domain.BatteryView.Reserve;
|
import home.backend.domain.BatteryView.Reserve;
|
||||||
import home.backend.domain.Command;
|
import home.backend.domain.Command;
|
||||||
import home.backend.domain.CommandResult;
|
import home.backend.domain.CommandResult;
|
||||||
|
import home.backend.domain.RefreshResult;
|
||||||
import home.backend.domain.RestoreResult;
|
import home.backend.domain.RestoreResult;
|
||||||
import home.backend.domain.SetpointState;
|
import home.backend.domain.SetpointState;
|
||||||
|
|
||||||
@@ -25,8 +27,8 @@ import java.time.Instant;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/** Sends commands to the gateway's {@code /commands} and {@code /battery} with the shared token (S6). */
|
/** Calls the gateway's {@code /commands}, {@code /battery} and {@code /refresh} with the shared token (S6). */
|
||||||
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
|
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery, GatewayRefresh {
|
||||||
|
|
||||||
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
|
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
|
||||||
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
|
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
|
||||||
@@ -66,6 +68,7 @@ public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
|
|||||||
private final URI endpoint;
|
private final URI endpoint;
|
||||||
private final URI renewEndpoint;
|
private final URI renewEndpoint;
|
||||||
private final URI restoreEndpoint;
|
private final URI restoreEndpoint;
|
||||||
|
private final URI refreshEndpoint;
|
||||||
private final URI reserveEndpoint;
|
private final URI reserveEndpoint;
|
||||||
private final URI scheduleEndpoint;
|
private final URI scheduleEndpoint;
|
||||||
private final String token;
|
private final String token;
|
||||||
@@ -77,6 +80,7 @@ public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
|
|||||||
this.endpoint = baseUri.resolve("/commands");
|
this.endpoint = baseUri.resolve("/commands");
|
||||||
this.renewEndpoint = baseUri.resolve("/commands/renew");
|
this.renewEndpoint = baseUri.resolve("/commands/renew");
|
||||||
this.restoreEndpoint = baseUri.resolve("/commands/restore");
|
this.restoreEndpoint = baseUri.resolve("/commands/restore");
|
||||||
|
this.refreshEndpoint = baseUri.resolve("/refresh");
|
||||||
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
|
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
|
||||||
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
|
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
|
||||||
this.token = token;
|
this.token = token;
|
||||||
@@ -113,6 +117,12 @@ public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
|
|||||||
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
|
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RefreshResult refresh() throws IOException {
|
||||||
|
var request = request(refreshEndpoint).POST(HttpRequest.BodyPublishers.noBody());
|
||||||
|
return JSON.readValue(send(request, Set.of(200)), RefreshResult.class);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Reserve reserve() throws IOException {
|
public Reserve reserve() throws IOException {
|
||||||
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
|
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
|||||||
import com.sun.net.httpserver.HttpExchange;
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
import com.sun.net.httpserver.HttpHandler;
|
import com.sun.net.httpserver.HttpHandler;
|
||||||
import com.sun.net.httpserver.HttpServer;
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import home.backend.adapters.auth.Authentication;
|
||||||
import home.backend.application.AuditService;
|
import home.backend.application.AuditService;
|
||||||
import home.backend.application.BatteryService;
|
import home.backend.application.BatteryService;
|
||||||
import home.backend.application.EnergyDetailsService;
|
import home.backend.application.EnergyDetailsService;
|
||||||
import home.backend.application.EnergyService;
|
import home.backend.application.EnergyService;
|
||||||
|
import home.backend.application.GatewayRefresh;
|
||||||
import home.backend.application.HealthService;
|
import home.backend.application.HealthService;
|
||||||
import home.backend.application.HeatPumpService;
|
import home.backend.application.HeatPumpService;
|
||||||
import home.backend.application.HistoryService;
|
import home.backend.application.HistoryService;
|
||||||
|
import home.backend.application.ProgramService;
|
||||||
import home.backend.application.StatsService;
|
import home.backend.application.StatsService;
|
||||||
import home.backend.application.PiSensorService;
|
import home.backend.application.PiSensorService;
|
||||||
import home.backend.application.SetpointService;
|
import home.backend.application.SetpointService;
|
||||||
@@ -54,8 +57,11 @@ public class ApiServer {
|
|||||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
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 {
|
PresetRequest {
|
||||||
if (values == null) {
|
if (values == null) {
|
||||||
@@ -72,11 +78,6 @@ public class ApiServer {
|
|||||||
private static final String PRESETS_PATH = "/api/presets/";
|
private static final String PRESETS_PATH = "/api/presets/";
|
||||||
private static final String SETPOINTS_PATH = "/api/setpoints/";
|
private static final String SETPOINTS_PATH = "/api/setpoints/";
|
||||||
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
|
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();
|
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
|
||||||
|
|
||||||
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
|
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
|
||||||
@@ -84,22 +85,40 @@ public class ApiServer {
|
|||||||
private static final int STOP_GRACE_SECONDS = 5;
|
private static final int STOP_GRACE_SECONDS = 5;
|
||||||
|
|
||||||
private final HttpServer server;
|
private final HttpServer server;
|
||||||
|
private final SessionFilter sessionFilter;
|
||||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
|
|
||||||
/** Binds the port now; 0 picks a free one. */
|
/** Binds the port now; 0 picks a free one. */
|
||||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||||
EnergyDetailsService energyDetails, HealthService health,
|
EnergyDetailsService energyDetails, HealthService health,
|
||||||
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
|
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
|
||||||
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather)
|
BatteryService battery, AuditService audit, StatsService stats, ProgramService program, WeatherService weather,
|
||||||
throws IOException {
|
GatewayRefresh refresh, Authentication auth) throws IOException {
|
||||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||||
server.setExecutor(executor);
|
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).
|
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
|
||||||
context("/", exchange -> {
|
open("/", exchange -> {
|
||||||
try (exchange) {
|
try (exchange) {
|
||||||
exchange.sendResponseHeaders(404, -1);
|
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/heat-pump", query -> heatPump.current());
|
||||||
api("/api/energy", query -> energy.current());
|
api("/api/energy", query -> energy.current());
|
||||||
api("/api/energy/details", query -> energyDetails.current());
|
api("/api/energy/details", query -> energyDetails.current());
|
||||||
@@ -108,12 +127,15 @@ public class ApiServer {
|
|||||||
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
|
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
|
||||||
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
|
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
|
||||||
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
|
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
|
||||||
|
route("/api/refresh", exchange -> handle(exchange, Map.of(
|
||||||
|
"POST", () -> guarded(exchange, body -> postRefresh(exchange, refresh)))));
|
||||||
api("/api/pi-sensor", query -> piSensor.current());
|
api("/api/pi-sensor", query -> piSensor.current());
|
||||||
api("/api/weather", query -> weather.current());
|
api("/api/weather", query -> weather.current());
|
||||||
api("/api/health", query -> health.current());
|
api("/api/health", query -> health.current());
|
||||||
route("/api/history", exchange -> handleHistory(exchange, history));
|
route("/api/history", exchange -> handleHistory(exchange, history));
|
||||||
route("/api/audit", exchange -> handleAudit(exchange, audit));
|
route("/api/audit", exchange -> handleAudit(exchange, audit));
|
||||||
route("/api/stats", exchange -> handleStats(exchange, stats));
|
route("/api/stats", exchange -> handleStats(exchange, stats));
|
||||||
|
route("/api/program", exchange -> handleProgram(exchange, program));
|
||||||
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
||||||
"GET", () -> getSetpoints(exchange, setpoints),
|
"GET", () -> getSetpoints(exchange, setpoints),
|
||||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||||
@@ -132,9 +154,17 @@ public class ApiServer {
|
|||||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
|
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
|
||||||
private void route(String path, HttpHandler handler) {
|
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())) {
|
if (path.equals(exchange.getRequestURI().getPath())) {
|
||||||
handler.handle(exchange);
|
handler.handle(exchange);
|
||||||
} else {
|
} else {
|
||||||
@@ -142,10 +172,18 @@ public class ApiServer {
|
|||||||
exchange.sendResponseHeaders(404, -1);
|
exchange.sendResponseHeaders(404, -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Needs a session. */
|
||||||
private void context(String path, HttpHandler handler) {
|
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);
|
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,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
|
* 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.
|
* 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();
|
var headers = exchange.getRequestHeaders();
|
||||||
String origin = headers.getFirst("Origin");
|
String origin = headers.getFirst("Origin");
|
||||||
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
|
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
|
||||||
@@ -259,6 +297,14 @@ public class ApiServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void postRefresh(HttpExchange exchange, GatewayRefresh refresh) throws IOException {
|
||||||
|
try {
|
||||||
|
send(exchange, 200, refresh.refresh());
|
||||||
|
} catch (IOException e) {
|
||||||
|
send(exchange, 503, error(UNREACHABLE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
|
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
try {
|
try {
|
||||||
@@ -273,14 +319,17 @@ public class ApiServer {
|
|||||||
throws IOException {
|
throws IOException {
|
||||||
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
||||||
try {
|
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 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()) {
|
if (result.isPresent()) {
|
||||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||||
} else {
|
} else {
|
||||||
send(exchange, 404, error("no such preset"));
|
send(exchange, 404, error("no such preset"));
|
||||||
}
|
}
|
||||||
|
} catch (SetpointService.InvalidHoursException e) {
|
||||||
|
send(exchange, 400, error("invalid_hours"));
|
||||||
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
|
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
|
||||||
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
|
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 "
|
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
|
||||||
@@ -326,9 +375,9 @@ public class ApiServer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The signed-in user, set by the session filter. */
|
||||||
private static String user(HttpExchange exchange) {
|
private static String user(HttpExchange exchange) {
|
||||||
String name = exchange.getRequestHeaders().getFirst(USER_HEADER);
|
return (String) exchange.getAttribute(SessionFilter.USER_ATTRIBUTE);
|
||||||
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
||||||
@@ -369,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;
|
void run() throws IOException;
|
||||||
}
|
}
|
||||||
|
|
||||||
private interface Write {
|
interface Write {
|
||||||
void run(byte[] body) throws IOException;
|
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) {
|
try (exchange) {
|
||||||
Action action = byMethod.get(exchange.getRequestMethod());
|
Action action = byMethod.get(exchange.getRequestMethod());
|
||||||
if (action == null) {
|
if (action == null) {
|
||||||
@@ -396,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);
|
byte[] bytes = JSON.writeValueAsBytes(body);
|
||||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||||
@@ -408,7 +469,7 @@ public class ApiServer {
|
|||||||
return Map.of("error", message);
|
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<>();
|
Map<String, String> query = new HashMap<>();
|
||||||
if (raw == null) {
|
if (raw == null) {
|
||||||
return query;
|
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.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import home.backend.domain.HeatPumpSample;
|
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.io.IOException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -19,7 +22,9 @@ final class InfluxSamples {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String query(long fromMs, long toMs) {
|
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";
|
+ " 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 series : result.path("series")) {
|
||||||
for (JsonNode row : series.path("values")) {
|
for (JsonNode row : series.path("values")) {
|
||||||
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
|
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
|
||||||
number(row, 1), number(row, 2), number(row, 3), number(row, 4),
|
number(row, 1), number(row, 2), number(row, 3),
|
||||||
number(row, 5), number(row, 6)));
|
number(row, 4), number(row, 5), periods(row)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return samples;
|
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) {
|
private static Double number(JsonNode row, int column) {
|
||||||
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
|
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ final class DayTally {
|
|||||||
private double compressorSeconds;
|
private double compressorSeconds;
|
||||||
private double cwuSeconds;
|
private double cwuSeconds;
|
||||||
private double coSeconds;
|
private double coSeconds;
|
||||||
private double heaterSeconds;
|
|
||||||
private int starts;
|
private int starts;
|
||||||
private int samples;
|
private int samples;
|
||||||
private double outdoorSum;
|
private double outdoorSum;
|
||||||
@@ -39,7 +38,6 @@ final class DayTally {
|
|||||||
compressorSeconds += on(s.compressor()) ? seconds : 0;
|
compressorSeconds += on(s.compressor()) ? seconds : 0;
|
||||||
cwuSeconds += on(s.cwu()) ? seconds : 0;
|
cwuSeconds += on(s.cwu()) ? seconds : 0;
|
||||||
coSeconds += on(s.co()) ? seconds : 0;
|
coSeconds += on(s.co()) ? seconds : 0;
|
||||||
heaterSeconds += on(s.heater()) ? seconds : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void start() {
|
void start() {
|
||||||
@@ -50,7 +48,6 @@ final class DayTally {
|
|||||||
compressorSeconds += other.compressorSeconds;
|
compressorSeconds += other.compressorSeconds;
|
||||||
cwuSeconds += other.cwuSeconds;
|
cwuSeconds += other.cwuSeconds;
|
||||||
coSeconds += other.coSeconds;
|
coSeconds += other.coSeconds;
|
||||||
heaterSeconds += other.heaterSeconds;
|
|
||||||
starts += other.starts;
|
starts += other.starts;
|
||||||
samples += other.samples;
|
samples += other.samples;
|
||||||
outdoorSum += other.outdoorSum;
|
outdoorSum += other.outdoorSum;
|
||||||
@@ -63,15 +60,15 @@ final class DayTally {
|
|||||||
|
|
||||||
Day day(LocalDate date) {
|
Day day(LocalDate date) {
|
||||||
if (samples == 0) {
|
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),
|
return new Day(date, hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
||||||
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
|
avgOutdoor(), minCwu, maxCwu);
|
||||||
}
|
}
|
||||||
|
|
||||||
Totals totals() {
|
Totals totals() {
|
||||||
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
||||||
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
|
avgOutdoor(), minCwu, maxCwu);
|
||||||
}
|
}
|
||||||
|
|
||||||
static boolean on(Double output) {
|
static boolean on(Double output) {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public class EnergyDetailsService {
|
|||||||
phases("grid_power_l%d_w", "Moc", "W", 0),
|
phases("grid_power_l%d_w", "Moc", "W", 0),
|
||||||
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
|
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
|
||||||
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
|
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),
|
field("load_power_w", "Moc razem", "W", 0),
|
||||||
phases("load_power_l%d_w", "Moc", "W", 0)),
|
phases("load_power_l%d_w", "Moc", "W", 0)),
|
||||||
group("Panele PV",
|
group("Panele PV",
|
||||||
@@ -81,14 +81,14 @@ public class EnergyDetailsService {
|
|||||||
field("pv2_current_a", "PV2 prąd", "A", 1)),
|
field("pv2_current_a", "PV2 prąd", "A", 1)),
|
||||||
group("Dziś",
|
group("Dziś",
|
||||||
field("pv_today_kwh", "Produkcja PV", "kWh", 1),
|
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_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
|
||||||
field("grid_sold_today_kwh", "Oddano do 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_charge_today_kwh", "Bateria naładowana", "kWh", 1),
|
||||||
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
|
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
|
||||||
group("Od początku",
|
group("Od początku",
|
||||||
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
|
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_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
|
||||||
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
|
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
|
||||||
field("battery_charge_total_kwh", "Bateria naładowana", "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.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.stream.Stream;
|
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 {
|
public class EnergyService {
|
||||||
|
|
||||||
private final GatewayClient gateway;
|
private final GatewayClient gateway;
|
||||||
@@ -30,17 +41,42 @@ public class EnergyService {
|
|||||||
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
|
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
|
||||||
Map<String, Double> d = deye.fields();
|
Map<String, Double> d = deye.fields();
|
||||||
Map<String, Double> f = fronius.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;
|
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)),
|
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());
|
List<Source> sources = List.of(deye.source(), fronius.source());
|
||||||
return new EnergyView(oldest(sources), sources,
|
return new EnergyView(oldest(sources), sources,
|
||||||
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
|
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"),
|
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);
|
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) {
|
private static Instant oldest(List<Source> sources) {
|
||||||
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.domain.RefreshResult;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/** Asks the gateway for fresh readings; it decides which devices may be polled again yet. */
|
||||||
|
public interface GatewayRefresh {
|
||||||
|
|
||||||
|
RefreshResult refresh() throws IOException;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import home.backend.domain.HeatPumpView.Setpoints;
|
|||||||
import home.backend.domain.HeatPumpView.State;
|
import home.backend.domain.HeatPumpView.State;
|
||||||
import home.backend.domain.HeatPumpView.Targets;
|
import home.backend.domain.HeatPumpView.Targets;
|
||||||
import home.backend.domain.HeatPumpView.Temperatures;
|
import home.backend.domain.HeatPumpView.Temperatures;
|
||||||
|
import home.backend.domain.Periods;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
@@ -43,7 +44,11 @@ public class HeatPumpService {
|
|||||||
|
|
||||||
private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) {
|
private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) {
|
||||||
Integer alarmCode = whole(f, "alarm_code");
|
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),
|
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
|
||||||
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
|
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
|
||||||
on(f, "output_ground"), on(f, "output_cwu")),
|
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, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
|
||||||
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
|
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
|
||||||
tenths(f, "outdoor_avg_temp")),
|
tenths(f, "outdoor_avg_temp")),
|
||||||
new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
|
targets, setpoints, Periods.of(whole(f, "program"), targets, setpoints));
|
||||||
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")));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Codes of register 4011 as the vendor app names them; unverified on our device. */
|
/** 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). */
|
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
||||||
public class SetpointService {
|
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 GatewayCommands gateway;
|
||||||
private final List<Preset> presets;
|
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.
|
* 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 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
|
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
|
||||||
* preset's value
|
* 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
|
* @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
|
* 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 {
|
throws IOException {
|
||||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||||
if (preset.isEmpty()) {
|
if (preset.isEmpty()) {
|
||||||
@@ -68,7 +81,7 @@ public class SetpointService {
|
|||||||
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
|
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
|
||||||
: "preset " + id + " has no end time to choose");
|
: "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();
|
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||||
requireWithinLimits(values, limits);
|
requireWithinLimits(values, limits);
|
||||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
|
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
|
||||||
@@ -87,6 +100,19 @@ public class SetpointService {
|
|||||||
c.hours())).toList();
|
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) {
|
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
|
||||||
for (Setpoint limit : limits) {
|
for (Setpoint limit : limits) {
|
||||||
Integer value = values.get(limit.name());
|
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 /
|
* 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.
|
* 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,
|
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. */
|
/** 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) {
|
public record Source(String name, Instant time, boolean stale) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
|
/** 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 loadKwh) {
|
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double houseKwh) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,6 @@ package home.backend.domain;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
|
/** 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,
|
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co,
|
||||||
Double outdoorTemp, Double cwuTemp) {
|
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
|
* 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.
|
* 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,
|
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. */
|
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
|
||||||
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** The gateway's answer to a refresh: the devices it will poll again, and those polled too recently. */
|
||||||
|
public record RefreshResult(List<String> requested, List<Skipped> skipped) {
|
||||||
|
|
||||||
|
public record Skipped(String device, long retryInSeconds) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,10 @@ import java.util.List;
|
|||||||
public record StatsView(String range, List<Day> days, Totals totals) {
|
public record StatsView(String range, List<Day> days, Totals totals) {
|
||||||
|
|
||||||
public record Day(LocalDate date, Double compressorHours, Integer compressorStarts, Double cwuHours,
|
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,
|
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.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.sun.net.httpserver.HttpExchange;
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
import com.sun.net.httpserver.HttpServer;
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import home.backend.adapters.auth.AuthSettings;
|
||||||
import home.backend.adapters.http.ApiServer;
|
import home.backend.adapters.http.ApiServer;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
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. */
|
/** 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 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 final HttpClient client = HttpClient.newHttpClient();
|
||||||
private HttpServer gateway;
|
private HttpServer gateway;
|
||||||
private HttpServer influx;
|
private HttpServer influx;
|
||||||
@@ -56,7 +59,7 @@ class BackendTest {
|
|||||||
gateway.createContext("/commands", this::commands);
|
gateway.createContext("/commands", this::commands);
|
||||||
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
|
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),
|
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 = Backend.assemble(settings, CLOCK);
|
||||||
backend.start();
|
backend.start();
|
||||||
}
|
}
|
||||||
@@ -83,7 +86,7 @@ class BackendTest {
|
|||||||
|
|
||||||
private HttpResponse<String> post(String path, String body) throws Exception {
|
private HttpResponse<String> post(String path, String body) throws Exception {
|
||||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
|
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")
|
.header("X-Requested-With", "home-pump")
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
||||||
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
@@ -199,7 +202,7 @@ class BackendTest {
|
|||||||
JSON.readTree(response.body()));
|
JSON.readTree(response.body()));
|
||||||
assertEquals(1, commandsSeen.size());
|
assertEquals(1, commandsSeen.size());
|
||||||
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
|
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"))),
|
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
|
||||||
JSON.readTree(commandsSeen.getFirst().body()));
|
JSON.readTree(commandsSeen.getFirst().body()));
|
||||||
}
|
}
|
||||||
@@ -209,7 +212,7 @@ class BackendTest {
|
|||||||
Path file = Files.writeString(dir.resolve("presets.json"),
|
Path file = Files.writeString(dir.resolve("presets.json"),
|
||||||
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
|
"[{\"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),
|
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();
|
custom.start();
|
||||||
try {
|
try {
|
||||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
|
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
|
||||||
@@ -229,7 +232,7 @@ class BackendTest {
|
|||||||
@Test
|
@Test
|
||||||
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
|
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
|
||||||
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
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();
|
noToken.start();
|
||||||
try {
|
try {
|
||||||
var base = "http://127.0.0.1:" + noToken.port();
|
var base = "http://127.0.0.1:" + noToken.port();
|
||||||
@@ -256,7 +259,7 @@ class BackendTest {
|
|||||||
@Test
|
@Test
|
||||||
void urlsMayEndWithASingleSlash() {
|
void urlsMayEndWithASingleSlash() {
|
||||||
var settings = Settings.fromEnvironment(
|
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());
|
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
|
||||||
}
|
}
|
||||||
@@ -264,7 +267,7 @@ class BackendTest {
|
|||||||
@Test
|
@Test
|
||||||
void settingsHaveDefaults() {
|
void settingsHaveDefaults() {
|
||||||
var settings = Settings.fromEnvironment(
|
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(8080, settings.port());
|
||||||
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
|
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
|
||||||
@@ -273,7 +276,8 @@ class BackendTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void tokenAndPresetsFileAreOptional() {
|
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 none = Settings.fromEnvironment(urls);
|
||||||
var env = new HashMap<>(urls);
|
var env = new HashMap<>(urls);
|
||||||
env.put("GATEWAY_TOKEN", " s3cret-token ");
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import home.backend.domain.BatteryResult;
|
|||||||
import home.backend.domain.BatteryView;
|
import home.backend.domain.BatteryView;
|
||||||
import home.backend.domain.Command;
|
import home.backend.domain.Command;
|
||||||
import home.backend.domain.CommandResult.Status;
|
import home.backend.domain.CommandResult.Status;
|
||||||
|
import home.backend.domain.RefreshResult;
|
||||||
import home.backend.domain.RestoreResult.Outcome;
|
import home.backend.domain.RestoreResult.Outcome;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -65,7 +66,7 @@ class GatewayHttpCommandsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void answerCommands() {
|
private void answerCommands() {
|
||||||
for (String path : List.of("/commands", "/battery")) {
|
for (String path : List.of("/commands", "/battery", "/refresh")) {
|
||||||
server.createContext(path, this::answer);
|
server.createContext(path, this::answer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,6 +226,29 @@ class GatewayHttpCommandsTest {
|
|||||||
assertEquals(seenBefore, seen.size());
|
assertEquals(seenBefore, seen.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshPostsToRefreshWithTheTokenAndParsesTheAnswer() throws Exception {
|
||||||
|
serve(200, Files.readString(Path.of("src/test/resources/api/refresh.json")));
|
||||||
|
|
||||||
|
var result = commands("s3cret-token").refresh();
|
||||||
|
|
||||||
|
assertEquals("POST", seen.getFirst().method());
|
||||||
|
assertEquals("/refresh", seen.getFirst().path());
|
||||||
|
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||||
|
assertEquals(List.of("deye", "house-meter"), result.requested());
|
||||||
|
assertEquals(new RefreshResult.Skipped("heat-pump", 41), result.skipped().getFirst());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshFailsOnAnyOtherStatusOrWithoutToken() {
|
||||||
|
serve(401, "{\"error\":\"x\"}");
|
||||||
|
assertThrows(IOException.class, () -> commands("s3cret-token").refresh());
|
||||||
|
|
||||||
|
var seenBefore = seen.size();
|
||||||
|
assertThrows(IOException.class, () -> commands(null).refresh());
|
||||||
|
assertEquals(seenBefore, seen.size());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void readsTheBatteryReserveWithTheToken() throws Exception {
|
void readsTheBatteryReserveWithTheToken() throws Exception {
|
||||||
serve(200, contract("battery-reserve.json"));
|
serve(200, contract("battery-reserve.json"));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import home.backend.application.AuditService;
|
|||||||
import home.backend.application.BatteryService;
|
import home.backend.application.BatteryService;
|
||||||
import home.backend.application.EnergyDetailsService;
|
import home.backend.application.EnergyDetailsService;
|
||||||
import home.backend.application.EnergyService;
|
import home.backend.application.EnergyService;
|
||||||
|
import home.backend.adapters.auth.Authentication;
|
||||||
import home.backend.adapters.config.PresetFile;
|
import home.backend.adapters.config.PresetFile;
|
||||||
import home.backend.application.GatewayClient;
|
import home.backend.application.GatewayClient;
|
||||||
import home.backend.application.GatewayBattery;
|
import home.backend.application.GatewayBattery;
|
||||||
@@ -18,6 +19,7 @@ import home.backend.domain.BatteryView.Reserve;
|
|||||||
import home.backend.domain.Command;
|
import home.backend.domain.Command;
|
||||||
import home.backend.domain.CommandResult;
|
import home.backend.domain.CommandResult;
|
||||||
import home.backend.domain.CommandResult.Status;
|
import home.backend.domain.CommandResult.Status;
|
||||||
|
import home.backend.domain.RefreshResult;
|
||||||
import home.backend.domain.RestoreResult;
|
import home.backend.domain.RestoreResult;
|
||||||
import home.backend.domain.RestoreResult.Outcome;
|
import home.backend.domain.RestoreResult.Outcome;
|
||||||
import home.backend.domain.Restore;
|
import home.backend.domain.Restore;
|
||||||
@@ -28,6 +30,7 @@ import home.backend.application.HeatPumpService;
|
|||||||
import home.backend.application.HistoryRepository;
|
import home.backend.application.HistoryRepository;
|
||||||
import home.backend.application.HeatPumpSamples;
|
import home.backend.application.HeatPumpSamples;
|
||||||
import home.backend.application.HistoryService;
|
import home.backend.application.HistoryService;
|
||||||
|
import home.backend.application.ProgramService;
|
||||||
import home.backend.application.StatsService;
|
import home.backend.application.StatsService;
|
||||||
import home.backend.application.PiSensorService;
|
import home.backend.application.PiSensorService;
|
||||||
import home.backend.application.WeatherService;
|
import home.backend.application.WeatherService;
|
||||||
@@ -37,6 +40,9 @@ import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
|||||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||||
import home.backend.domain.WeatherView.Warning;
|
import home.backend.domain.WeatherView.Warning;
|
||||||
import home.backend.domain.HeatPumpSample;
|
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 home.backend.domain.HistoryView.Point;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -51,6 +57,7 @@ import java.net.http.HttpRequest;
|
|||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
@@ -80,6 +87,7 @@ class ApiServerTest {
|
|||||||
private final List<Command> sent = new ArrayList<>();
|
private final List<Command> sent = new ArrayList<>();
|
||||||
private Status verdict;
|
private Status verdict;
|
||||||
private Outcome restoreOutcome;
|
private Outcome restoreOutcome;
|
||||||
|
private int refreshes;
|
||||||
private final List<String> restored = new ArrayList<>();
|
private final List<String> restored = new ArrayList<>();
|
||||||
private boolean commandsDown;
|
private boolean commandsDown;
|
||||||
private boolean batteryDown;
|
private boolean batteryDown;
|
||||||
@@ -113,10 +121,10 @@ class ApiServerTest {
|
|||||||
throw new IOException("influx down");
|
throw new IOException("influx down");
|
||||||
}
|
}
|
||||||
return List.of(
|
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: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, 0.0, 8.5, 43.0),
|
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, 100.0, 9.0, 45.0),
|
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, 0.0, 9.0, 46.0));
|
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 9.0, 46.0, Periods.UNKNOWN));
|
||||||
};
|
};
|
||||||
verdict = Status.APPLIED;
|
verdict = Status.APPLIED;
|
||||||
restoreOutcome = Outcome.RESTORED;
|
restoreOutcome = Outcome.RESTORED;
|
||||||
@@ -203,8 +211,17 @@ class ApiServerTest {
|
|||||||
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
|
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
|
||||||
new BatteryService(gateway, batteryCommands, clock),
|
new BatteryService(gateway, batteryCommands, clock),
|
||||||
new AuditService(auditRows(), clock), new StatsService(samples, clock),
|
new AuditService(auditRows(), clock), new StatsService(samples, clock),
|
||||||
|
new ProgramService((from, to) -> samples.samples(from, to), clock),
|
||||||
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
|
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
|
||||||
(from, to) -> exampleMeasuredPv(), clock));
|
(from, to) -> exampleMeasuredPv(), clock),
|
||||||
|
() -> {
|
||||||
|
if (commandsDown) {
|
||||||
|
throw new IOException("tunnel down");
|
||||||
|
}
|
||||||
|
refreshes++;
|
||||||
|
return new RefreshResult(List.of("deye", "house-meter"),
|
||||||
|
List.of(new RefreshResult.Skipped("heat-pump", 41)));
|
||||||
|
}, Authentication.disabled());
|
||||||
server.start();
|
server.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,10 +256,10 @@ class ApiServerTest {
|
|||||||
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
|
"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_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,
|
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));
|
"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,
|
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 pumpTime = Instant.parse("2026-10-23T22:13:35Z");
|
||||||
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
|
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,
|
Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||||
@@ -263,7 +280,7 @@ class ApiServerTest {
|
|||||||
new DeviceSnapshot("house-meter", true, energyTime, null,
|
new DeviceSnapshot("house-meter", true, energyTime, null,
|
||||||
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
|
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,
|
"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,
|
new DeviceSnapshot("deye-settings", true, energyTime, null,
|
||||||
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
|
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
|
||||||
new DeviceSnapshot("bme280", true, energyTime, null,
|
new DeviceSnapshot("bme280", true, energyTime, null,
|
||||||
@@ -379,13 +396,12 @@ class ApiServerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
|
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
|
||||||
var response = post("/api/battery/schedule", example("battery-schedule-request").toString(),
|
var response = post("/api/battery/schedule", example("battery-schedule-request").toString());
|
||||||
"Remote-User", "anna");
|
|
||||||
|
|
||||||
assertEquals(200, response.statusCode());
|
assertEquals(200, response.statusCode());
|
||||||
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
|
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
|
||||||
new BatteryChange.Slot(2, java.time.LocalTime.of(9, 30), 90, true),
|
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}}"),
|
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
|
||||||
JSON.readTree(response.body()));
|
JSON.readTree(response.body()));
|
||||||
}
|
}
|
||||||
@@ -518,6 +534,38 @@ class ApiServerTest {
|
|||||||
assertEquals(502, get("/api/stats?range=7d").statusCode());
|
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
|
@Test
|
||||||
void historyRangeDefaultsTo24Hours() throws Exception {
|
void historyRangeDefaultsTo24Hours() throws Exception {
|
||||||
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
||||||
@@ -632,11 +680,11 @@ class ApiServerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
|
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(200, response.statusCode());
|
||||||
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
|
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
|
@Test
|
||||||
@@ -667,6 +715,32 @@ class ApiServerTest {
|
|||||||
assertTrue(restored.isEmpty());
|
assertTrue(restored.isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshPassesTheGatewaysAnswerThrough() throws Exception {
|
||||||
|
var response = post("/api/refresh", "{}");
|
||||||
|
|
||||||
|
assertEquals(200, response.statusCode());
|
||||||
|
assertEquals(example("refresh"), JSON.readTree(response.body()));
|
||||||
|
assertEquals(1, refreshes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshIs503WhenTheGatewayCannotBeReached() throws Exception {
|
||||||
|
commandsDown = true;
|
||||||
|
|
||||||
|
assertEquals(503, post("/api/refresh", "{}").statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshIsGuardedLikeEveryWrite() throws Exception {
|
||||||
|
String path = "/api/refresh";
|
||||||
|
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
|
||||||
|
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
|
||||||
|
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
|
||||||
|
assertEquals(405, get(path).statusCode());
|
||||||
|
assertEquals(0, refreshes);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void otherPathsUnderSetpointsAre404() throws Exception {
|
void otherPathsUnderSetpointsAre404() throws Exception {
|
||||||
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
|
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
|
||||||
@@ -694,12 +768,10 @@ class ApiServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception {
|
void aRemoteUserHeaderIsNotTrusted() throws Exception {
|
||||||
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl");
|
post("/api/setpoints", BOOST, "Remote-User", "anna");
|
||||||
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
|
|
||||||
post("/api/setpoints", BOOST);
|
|
||||||
|
|
||||||
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
|
@Test
|
||||||
@@ -791,6 +863,25 @@ class ApiServerTest {
|
|||||||
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
|
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
|
@Test
|
||||||
void unknownPresetIs404() throws Exception {
|
void unknownPresetIs404() throws Exception {
|
||||||
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package home.backend.adapters.influx;
|
|||||||
|
|
||||||
import com.sun.net.httpserver.HttpServer;
|
import com.sun.net.httpserver.HttpServer;
|
||||||
import home.backend.domain.HeatPumpSample;
|
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.HistoryView.Point;
|
||||||
import home.backend.domain.Series;
|
import home.backend.domain.Series;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
@@ -216,13 +218,16 @@ class InfluxHistoryRepositoryTest {
|
|||||||
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
|
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
|
||||||
|
|
||||||
assertEquals("Dom_Pompa", requests.get(0).get("db"));
|
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",
|
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
|
||||||
requests.get(0).get("q"));
|
requests.get(0).get("q"));
|
||||||
assertEquals(3, samples.size());
|
assertEquals(3, samples.size());
|
||||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 0.0, 8.5, 43.9),
|
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 8.5, 43.9,
|
||||||
samples.get(1));
|
new Periods(Period.COMFORT, Period.COMFORT, 1, null)), samples.get(1));
|
||||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null),
|
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));
|
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;
|
||||||
import home.backend.domain.HeatPumpView.State;
|
import home.backend.domain.HeatPumpView.State;
|
||||||
import home.backend.domain.HistoryView;
|
import home.backend.domain.HistoryView;
|
||||||
|
import home.backend.domain.Period;
|
||||||
import home.backend.domain.PiSensorView;
|
import home.backend.domain.PiSensorView;
|
||||||
import home.backend.domain.HistoryView.Point;
|
import home.backend.domain.HistoryView.Point;
|
||||||
import home.backend.domain.Series;
|
import home.backend.domain.Series;
|
||||||
@@ -71,14 +72,39 @@ class ServicesTest {
|
|||||||
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
|
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
|
@Test
|
||||||
void heatPumpMapsOutputsAlarmAndTime() throws IOException {
|
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));
|
"output_cwu", 100.0, "output_co", 0.0, "alarm", 4.0, "setpoint_cwu_economy", 8.0));
|
||||||
|
|
||||||
assertEquals(READ, view.time());
|
assertEquals(READ, view.time());
|
||||||
assertEquals(66, view.compressorPercent());
|
assertEquals(66, view.compressorPercent());
|
||||||
assertTrue(view.heaterOn());
|
|
||||||
assertTrue(view.alarm());
|
assertTrue(view.alarm());
|
||||||
assertTrue(view.pumps().cwu());
|
assertTrue(view.pumps().cwu());
|
||||||
assertFalse(view.pumps().co());
|
assertFalse(view.pumps().co());
|
||||||
@@ -402,7 +428,8 @@ class ServicesTest {
|
|||||||
assertEquals(2000.0, view.pvPowerW());
|
assertEquals(2000.0, view.pvPowerW());
|
||||||
assertEquals(4.4, view.today().pvKwh());
|
assertEquals(4.4, view.today().pvKwh());
|
||||||
assertNull(view.gridPowerW());
|
assertNull(view.gridPowerW());
|
||||||
assertNull(view.today().loadKwh());
|
assertNull(view.housePowerW());
|
||||||
|
assertNull(view.today().houseKwh());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -415,6 +442,82 @@ class ServicesTest {
|
|||||||
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
|
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 {
|
private static EnergyDetails details(DeviceSnapshot... devices) throws IOException {
|
||||||
return new EnergyDetailsService(gateway(devices), CLOCK).current();
|
return new EnergyDetailsService(gateway(devices), CLOCK).current();
|
||||||
}
|
}
|
||||||
@@ -506,8 +609,9 @@ class ServicesTest {
|
|||||||
assertNull(view.gridPowerW());
|
assertNull(view.gridPowerW());
|
||||||
assertNull(view.batteryPowerW());
|
assertNull(view.batteryPowerW());
|
||||||
assertNull(view.batterySocPct());
|
assertNull(view.batterySocPct());
|
||||||
assertNull(view.loadPowerW());
|
assertNull(view.housePowerW());
|
||||||
assertNull(view.today().loadKwh());
|
assertNull(view.backupOutputW());
|
||||||
|
assertNull(view.today().houseKwh());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -127,15 +127,38 @@ class SetpointServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void boostPresetSendsOneTimedCommand() throws Exception {
|
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(PresetResult.Status.APPLIED, result.status());
|
||||||
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
|
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
|
@Test
|
||||||
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
|
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(2, result.results().size());
|
||||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||||
@@ -144,7 +167,7 @@ class SetpointServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
|
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"),
|
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||||
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
|
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
|
||||||
@@ -152,7 +175,7 @@ class SetpointServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
|
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);
|
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
|
||||||
}
|
}
|
||||||
@@ -160,7 +183,7 @@ class SetpointServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
|
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
|
||||||
assertThrows(IllegalArgumentException.class,
|
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());
|
assertTrue(sent.isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,14 +191,14 @@ class SetpointServiceTest {
|
|||||||
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
|
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
|
||||||
for (int value : List.of(4, 26, 0)) {
|
for (int value : List.of(4, 26, 0)) {
|
||||||
assertThrows(IllegalArgumentException.class,
|
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());
|
assertTrue(sent.isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aHeldValueThatDoesNotLowerTheCurrentOneWritesNothing() throws Exception {
|
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(PresetResult.Status.REJECTED, result.status());
|
||||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||||
@@ -184,8 +207,8 @@ class SetpointServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
|
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
|
||||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, Map.of(), "anna"));
|
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, null, Map.of(), "anna"));
|
||||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, Map.of(), "anna"));
|
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, null, Map.of(), "anna"));
|
||||||
assertTrue(sent.isEmpty());
|
assertTrue(sent.isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +217,7 @@ class SetpointServiceTest {
|
|||||||
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||||
new Change("co_comfort", 19, 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(PresetResult.Status.REJECTED, result.status());
|
||||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||||
@@ -204,7 +227,7 @@ class SetpointServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unknownPresetIsEmpty() throws Exception {
|
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());
|
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),
|
var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null),
|
||||||
new Change("cwu_economy", 60, 2.0)));
|
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(PresetResult.Status.REJECTED, result.status());
|
||||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||||
@@ -225,7 +248,7 @@ class SetpointServiceTest {
|
|||||||
void presetWithUnknownSetpointWritesNothing() throws Exception {
|
void presetWithUnknownSetpointWritesNothing() throws Exception {
|
||||||
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
|
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
|
||||||
|
|
||||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", 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());
|
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||||
assertTrue(sent.isEmpty());
|
assertTrue(sent.isEmpty());
|
||||||
@@ -239,7 +262,7 @@ class SetpointServiceTest {
|
|||||||
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
|
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
|
||||||
script.add(Status.FAILED);
|
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(PresetResult.Status.FAILED, result.status());
|
||||||
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||||
@@ -250,7 +273,7 @@ class SetpointServiceTest {
|
|||||||
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
|
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
|
||||||
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
|
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(PresetResult.Status.PARTIAL, result.status());
|
||||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
|
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
|
||||||
@@ -260,7 +283,7 @@ class SetpointServiceTest {
|
|||||||
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
|
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
|
||||||
script.add(Status.REJECTED);
|
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(PresetResult.Status.REJECTED, result.status());
|
||||||
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||||
@@ -270,7 +293,7 @@ class SetpointServiceTest {
|
|||||||
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
|
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
|
||||||
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
|
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(PresetResult.Status.PARTIAL, result.status());
|
||||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
|
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
|
||||||
@@ -280,7 +303,7 @@ class SetpointServiceTest {
|
|||||||
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
|
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
|
||||||
script.add(new IOException("tunnel dropped"));
|
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(PresetResult.Status.FAILED, result.status());
|
||||||
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
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::current);
|
||||||
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
|
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());
|
assertTrue(sent.isEmpty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package home.backend.application;
|
package home.backend.application;
|
||||||
|
|
||||||
import home.backend.domain.HeatPumpSample;
|
import home.backend.domain.HeatPumpSample;
|
||||||
|
import home.backend.domain.Periods;
|
||||||
import home.backend.domain.StatsView;
|
import home.backend.domain.StatsView;
|
||||||
import home.backend.domain.StatsView.Day;
|
import home.backend.domain.StatsView.Day;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -35,7 +36,7 @@ class StatsServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static HeatPumpSample compressor(String time, double stage) {
|
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) {
|
private static Day day(StatsView view, String date) {
|
||||||
@@ -158,15 +159,14 @@ class StatsServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void sumsEachOutputSeparately() throws IOException {
|
void sumsEachOutputSeparately() throws IOException {
|
||||||
StatsView view = stats("7d", NOW,
|
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: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, 100.0, 12.0, 38.0),
|
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, 0.0, null, null));
|
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");
|
Day d = day(view, "2026-10-23");
|
||||||
assertEquals(0.17, d.compressorHours());
|
assertEquals(0.17, d.compressorHours());
|
||||||
assertEquals(0.08, d.cwuHours());
|
assertEquals(0.08, d.cwuHours());
|
||||||
assertEquals(0.08, d.coHours());
|
assertEquals(0.08, d.coHours());
|
||||||
assertEquals(0.08, d.heaterHours());
|
|
||||||
assertEquals(11.0, d.avgOutdoorTemp());
|
assertEquals(11.0, d.avgOutdoorTemp());
|
||||||
assertEquals(38.0, d.minCwuTemp());
|
assertEquals(38.0, d.minCwuTemp());
|
||||||
assertEquals(40.0, d.maxCwuTemp());
|
assertEquals(40.0, d.maxCwuTemp());
|
||||||
@@ -175,13 +175,12 @@ class StatsServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void totalsCoverTheWholeRangeWithTheTemperatureOfAllPolls() throws IOException {
|
void totalsCoverTheWholeRangeWithTheTemperatureOfAllPolls() throws IOException {
|
||||||
StatsView view = stats("7d", NOW,
|
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: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, 0.0, 10.0, 31.0),
|
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, 100.0, 4.0, 45.0),
|
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, 0.0, 4.0, 44.0));
|
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.17, view.totals().compressorHours());
|
||||||
assertEquals(0.08, view.totals().heaterHours());
|
|
||||||
assertEquals(7.0, view.totals().avgOutdoorTemp());
|
assertEquals(7.0, view.totals().avgOutdoorTemp());
|
||||||
assertEquals(30.0, view.totals().minCwuTemp());
|
assertEquals(30.0, view.totals().minCwuTemp());
|
||||||
assertEquals(45.0, view.totals().maxCwuTemp());
|
assertEquals(45.0, view.totals().maxCwuTemp());
|
||||||
@@ -190,7 +189,7 @@ class StatsServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void aMissingFieldCountsAsOffAndUnknownStateIsNoStart() throws IOException {
|
void aMissingFieldCountsAsOffAndUnknownStateIsNoStart() throws IOException {
|
||||||
StatsView view = stats("7d", NOW,
|
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));
|
compressor("2026-10-23T10:05:00Z", 100));
|
||||||
|
|
||||||
assertEquals(0, day(view, "2026-10-23").compressorStarts());
|
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": [
|
{ "title": "Sieć", "items": [
|
||||||
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
|
{ "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 }
|
{ "label": "Moc razem", "value": 570.0, "unit": "W", "digits": 0 }
|
||||||
] },
|
] },
|
||||||
{ "title": "Panele PV", "items": [
|
{ "title": "Panele PV", "items": [
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
] },
|
] },
|
||||||
{ "title": "Dziś", "items": [
|
{ "title": "Dziś", "items": [
|
||||||
{ "label": "Produkcja PV", "value": 29.9, "unit": "kWh", "digits": 1 },
|
{ "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": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
|
||||||
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 },
|
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 },
|
||||||
{ "label": "Bateria naładowana", "value": 0.5, "unit": "kWh", "digits": 1 },
|
{ "label": "Bateria 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,
|
"id": "house-meter", "name": "Licznik domu", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||||
"groups": [
|
"groups": [
|
||||||
{ "title": "Moc czynna", "items": [
|
{ "title": "Moc czynna", "items": [
|
||||||
|
{ "label": "Razem", "value": 389.0, "unit": "W", "digits": 0 },
|
||||||
{ "label": "Moc L1", "value": 56.0, "unit": "W", "digits": 0 }
|
{ "label": "Moc L1", "value": 56.0, "unit": "W", "digits": 0 }
|
||||||
] },
|
] },
|
||||||
{ "title": "Napięcie", "items": [
|
{ "title": "Napięcie", "items": [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"gridPowerW": null,
|
"gridPowerW": null,
|
||||||
"batteryPowerW": null,
|
"batteryPowerW": null,
|
||||||
"batterySocPct": null,
|
"batterySocPct": null,
|
||||||
"loadPowerW": null,
|
"housePowerW": null,
|
||||||
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "loadKwh": null }
|
"backupOutputW": null,
|
||||||
|
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "houseKwh": null }
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"gridPowerW": 645.0,
|
"gridPowerW": 645.0,
|
||||||
"batteryPowerW": -24.0,
|
"batteryPowerW": -24.0,
|
||||||
"batterySocPct": 99.0,
|
"batterySocPct": 99.0,
|
||||||
"loadPowerW": 570.0,
|
"housePowerW": 364.78,
|
||||||
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "loadKwh": 24.2 }
|
"backupOutputW": 570.0,
|
||||||
|
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "houseKwh": 28.4 }
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
"stale": true,
|
"stale": true,
|
||||||
"state": null,
|
"state": null,
|
||||||
"compressorPercent": null,
|
"compressorPercent": null,
|
||||||
"heaterOn": false,
|
|
||||||
"alarm": false,
|
"alarm": false,
|
||||||
"alarmCode": null,
|
"alarmCode": null,
|
||||||
"alarmText": null,
|
"alarmText": null,
|
||||||
@@ -13,5 +12,6 @@
|
|||||||
"groundIn": null, "groundOut": null, "pumpOutlet": null, "outdoorAvg": null
|
"groundIn": null, "groundOut": null, "pumpOutlet": null, "outdoorAvg": null
|
||||||
},
|
},
|
||||||
"targets": { "room": null, "co": null, "cwu": 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,
|
"stale": false,
|
||||||
"state": "IDLE",
|
"state": "IDLE",
|
||||||
"compressorPercent": 0,
|
"compressorPercent": 0,
|
||||||
"heaterOn": false,
|
|
||||||
"alarm": false,
|
"alarm": false,
|
||||||
"alarmCode": null,
|
"alarmCode": null,
|
||||||
"alarmText": null,
|
"alarmText": null,
|
||||||
@@ -13,5 +12,6 @@
|
|||||||
"groundIn": 16.0, "groundOut": 14.3, "pumpOutlet": 19.7, "outdoorAvg": 4.8
|
"groundIn": 16.0, "groundOut": 14.3, "pumpOutlet": 19.7, "outdoorAvg": 4.8
|
||||||
},
|
},
|
||||||
"targets": { "room": 18.0, "co": 31.0, "cwu": 8.0 },
|
"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": [] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"requested": ["deye", "house-meter"],
|
||||||
|
"skipped": [{ "device": "heat-pump", "retryInSeconds": 41 }]
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
"compressorStarts": null,
|
"compressorStarts": null,
|
||||||
"cwuHours": null,
|
"cwuHours": null,
|
||||||
"coHours": null,
|
"coHours": null,
|
||||||
"heaterHours": null,
|
|
||||||
"avgOutdoorTemp": null,
|
"avgOutdoorTemp": null,
|
||||||
"minCwuTemp": null,
|
"minCwuTemp": null,
|
||||||
"maxCwuTemp": null
|
"maxCwuTemp": null
|
||||||
@@ -18,7 +17,6 @@
|
|||||||
"compressorStarts": null,
|
"compressorStarts": null,
|
||||||
"cwuHours": null,
|
"cwuHours": null,
|
||||||
"coHours": null,
|
"coHours": null,
|
||||||
"heaterHours": null,
|
|
||||||
"avgOutdoorTemp": null,
|
"avgOutdoorTemp": null,
|
||||||
"minCwuTemp": null,
|
"minCwuTemp": null,
|
||||||
"maxCwuTemp": null
|
"maxCwuTemp": null
|
||||||
@@ -29,7 +27,6 @@
|
|||||||
"compressorStarts": null,
|
"compressorStarts": null,
|
||||||
"cwuHours": null,
|
"cwuHours": null,
|
||||||
"coHours": null,
|
"coHours": null,
|
||||||
"heaterHours": null,
|
|
||||||
"avgOutdoorTemp": null,
|
"avgOutdoorTemp": null,
|
||||||
"minCwuTemp": null,
|
"minCwuTemp": null,
|
||||||
"maxCwuTemp": null
|
"maxCwuTemp": null
|
||||||
@@ -40,7 +37,6 @@
|
|||||||
"compressorStarts": null,
|
"compressorStarts": null,
|
||||||
"cwuHours": null,
|
"cwuHours": null,
|
||||||
"coHours": null,
|
"coHours": null,
|
||||||
"heaterHours": null,
|
|
||||||
"avgOutdoorTemp": null,
|
"avgOutdoorTemp": null,
|
||||||
"minCwuTemp": null,
|
"minCwuTemp": null,
|
||||||
"maxCwuTemp": null
|
"maxCwuTemp": null
|
||||||
@@ -51,7 +47,6 @@
|
|||||||
"compressorStarts": null,
|
"compressorStarts": null,
|
||||||
"cwuHours": null,
|
"cwuHours": null,
|
||||||
"coHours": null,
|
"coHours": null,
|
||||||
"heaterHours": null,
|
|
||||||
"avgOutdoorTemp": null,
|
"avgOutdoorTemp": null,
|
||||||
"minCwuTemp": null,
|
"minCwuTemp": null,
|
||||||
"maxCwuTemp": null
|
"maxCwuTemp": null
|
||||||
@@ -62,7 +57,6 @@
|
|||||||
"compressorStarts": 1,
|
"compressorStarts": 1,
|
||||||
"cwuHours": 0.08,
|
"cwuHours": 0.08,
|
||||||
"coHours": 0.0,
|
"coHours": 0.0,
|
||||||
"heaterHours": 0.08,
|
|
||||||
"avgOutdoorTemp": 8.6,
|
"avgOutdoorTemp": 8.6,
|
||||||
"minCwuTemp": 43.0,
|
"minCwuTemp": 43.0,
|
||||||
"maxCwuTemp": 46.0
|
"maxCwuTemp": 46.0
|
||||||
@@ -73,7 +67,6 @@
|
|||||||
"compressorStarts": null,
|
"compressorStarts": null,
|
||||||
"cwuHours": null,
|
"cwuHours": null,
|
||||||
"coHours": null,
|
"coHours": null,
|
||||||
"heaterHours": null,
|
|
||||||
"avgOutdoorTemp": null,
|
"avgOutdoorTemp": null,
|
||||||
"minCwuTemp": null,
|
"minCwuTemp": null,
|
||||||
"maxCwuTemp": null
|
"maxCwuTemp": null
|
||||||
@@ -84,7 +77,6 @@
|
|||||||
"compressorStarts": 1,
|
"compressorStarts": 1,
|
||||||
"cwuHours": 0.08,
|
"cwuHours": 0.08,
|
||||||
"coHours": 0.0,
|
"coHours": 0.0,
|
||||||
"heaterHours": 0.08,
|
|
||||||
"avgOutdoorTemp": 8.6,
|
"avgOutdoorTemp": 8.6,
|
||||||
"minCwuTemp": 43.0,
|
"minCwuTemp": 43.0,
|
||||||
"maxCwuTemp": 46.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
|
# 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
|
## Context
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -24,9 +24,11 @@ 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 |
|
| 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 |
|
| 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 | |
|
| 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 |
|
| 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 |
|
| 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).
|
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.
|
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.
|
- 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).
|
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).
|
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.
|
- 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.
|
||||||
+1
-1
@@ -38,7 +38,7 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|
|||||||
| Milestone | Scope | Done when |
|
| 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 |
|
| 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 |
|
| 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); 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 |
|
| 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 |
|
| M4b Battery settings | F8 (ADR-013): Time of Use table and backup reserve from the Bateria view, FC16 with read-back, refused on stale or changed state; on branch/master and off until `deyeCommands.enabled` (gateway/README.md: Enabling battery commands) | Father changes the battery program from the app; first live write watched |
|
||||||
| M5 Alarms | F4; decide the channel first (e.g. ntfy, Telegram) | Simulated alarm reaches the phone |
|
| M5 Alarms | F4; decide the channel first (e.g. ntfy, Telegram) | Simulated alarm reaches the phone |
|
||||||
|
|||||||
+7
-5
@@ -74,9 +74,10 @@ Layers in both apps, dependencies point inwards only:
|
|||||||
Functional:
|
Functional:
|
||||||
|
|
||||||
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
|
- 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.
|
pressure) of the living room. An "Odśwież" button asks for one extra poll of each device, at most once
|
||||||
- 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).
|
starts "at least" because polls are 5 minutes apart).
|
||||||
- F3 Change four setpoints: economy and comfort temperature of CWU (4143, 4144) and CO
|
- 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
|
(4131, 4132). Any value within limits, or one tap on a named preset. Limits and presets live in
|
||||||
@@ -91,7 +92,8 @@ Functional:
|
|||||||
through the same checks.
|
through the same checks.
|
||||||
- F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery).
|
- F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery).
|
||||||
- F5 Health of backend, device links and tunnel.
|
- 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.
|
- 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.
|
- 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.
|
||||||
@@ -110,7 +112,7 @@ Safety, for every write:
|
|||||||
|
|
||||||
Non-functional:
|
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.
|
- N2 Recovers on its own from device, InfluxDB and tunnel outages.
|
||||||
- N3 Existing Grafana dashboards keep working.
|
- N3 Existing Grafana dashboards keep working.
|
||||||
- N4 Usable by my parents: large controls, few screens, Polish UI.
|
- N4 Usable by my parents: large controls, few screens, Polish UI.
|
||||||
@@ -20,6 +20,10 @@ and gateway's versions in the "Wersje" card of the Stan screen.
|
|||||||
The Energia screen has three views, kept in the URL hash so Back works: `#energy` (Teraz), `#energy/battery` (Bateria, also
|
The Energia screen has three views, kept in the URL hash so Back works: `#energy` (Teraz), `#energy/battery` (Bateria, also
|
||||||
opened by tapping the battery in the flow diagram) and `#energy/inverters` (Falowniki, the device details).
|
opened by tapping the battery in the flow diagram) and `#energy/inverters` (Falowniki, the device details).
|
||||||
|
|
||||||
|
Every screen title (`PageTitle`) has the "Odśwież" button (`Refresh.tsx`) beside it, with its status text below: it posts `api/refresh`, waits 5 s for the gateway's extra poll, then
|
||||||
|
`refreshAll()` (`poll.ts`) fetches every poller that is on screen again. When the gateway skipped every device (polled within a minute) it says so and
|
||||||
|
waits out the shortest cooldown.
|
||||||
|
|
||||||
Ustawienia ends with the "Historia zmian" card (`ChangeHistory.tsx`): the last 7 days of `api/audit`, 20 rows and "Pokaż więcej".
|
Ustawienia ends with the "Historia zmian" card (`ChangeHistory.tsx`): the last 7 days of `api/audit`, 20 rows and "Pokaż więcej".
|
||||||
|
|
||||||
In the Bateria view, `editable` from `api/battery` (the gateway takes battery changes, ADR-013) adds the reserve stepper and "Edytuj" for the
|
In the Bateria view, `editable` from `api/battery` (the gateway takes battery changes, ADR-013) adds the reserve stepper and "Edytuj" for the
|
||||||
@@ -31,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
|
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.
|
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)
|
## 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
|
The build has a manifest (`public/`), icons and a service worker (`src/sw.js`, written to `dist/sw.js` with the list of built
|
||||||
|
|||||||
@@ -20,10 +20,9 @@ services:
|
|||||||
traefik.http.routers.home-pump.tls.certresolver: http
|
traefik.http.routers.home-pump.tls.certresolver: http
|
||||||
traefik.http.routers.home-pump.service: home-pump
|
traefik.http.routers.home-pump.service: home-pump
|
||||||
traefik.http.services.home-pump.loadbalancer.server.port: "8080"
|
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-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
|
||||||
traefik.http.routers.home-pump.middlewares: home-pump-krosno,home-pump-no-user
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
home-pump:
|
home-pump:
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ server {
|
|||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass ${BACKEND_URL};
|
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.
|
# With the port: the backend compares Origin with Host before a write.
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_read_timeout 30s;
|
proxy_read_timeout 30s;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lazy, Suspense, useEffect, useState, type ComponentType } from "react";
|
import { lazy, Suspense, useEffect, useState, type ComponentType } from "react";
|
||||||
|
import { Account } from "./Auth";
|
||||||
import { celsius, hour, longDate } from "./format";
|
import { celsius, hour, longDate } from "./format";
|
||||||
import { Icon, type IconName } from "./icons";
|
import { Icon, type IconName } from "./icons";
|
||||||
import { Energy } from "./Energy";
|
import { Energy } from "./Energy";
|
||||||
@@ -70,6 +71,7 @@ function SideFooter() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
|
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
|
||||||
<ThemeSwitch className="bg-white/10" />
|
<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">
|
<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} />
|
<Dot tone={offline ? "bad" : old ? "warn" : "ok"} live={!offline && !old} />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
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 type { HeatPump, PiSensor } from "./types";
|
||||||
import { Card, Row } from "./ui";
|
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="Ciśnienie" value={reading(pi?.pressure, 0, "hPa")} />
|
||||||
<Row label="Pokój według pompy" value={celsius(t.indoor)} />
|
<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, 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)} />
|
<Row label="Na zewnątrz, średnia" value={celsius(t.outdoorAvg)} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group title="Woda i ogrzewanie">
|
<Group title="Woda i ogrzewanie">
|
||||||
|
|||||||
@@ -109,6 +109,17 @@ describe("Energy details", () => {
|
|||||||
expect(screen.getByRole("button", { name: /Falownik Fronius/ })).not.toHaveTextContent("PV");
|
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 () => {
|
it("dims the values of a source that has gone quiet", async () => {
|
||||||
justAfterFixture();
|
justAfterFixture();
|
||||||
const e = example<EnergyData>("energy");
|
const e = example<EnergyData>("energy");
|
||||||
@@ -120,7 +131,7 @@ describe("Energy details", () => {
|
|||||||
const stats = (await screen.findByText("Zużycie domu")).closest("section")!;
|
const stats = (await screen.findByText("Zużycie domu")).closest("section")!;
|
||||||
expect(screen.getByText("Produkcja PV", { selector: "p" }).closest("section")).toHaveClass("opacity-60");
|
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(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 () => {
|
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();
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
// A source without a fresh reading keeps its last value on the screen, so the value is dimmed.
|
||||||
function quietNodes(e: EnergyData): string[] {
|
function quietNodes(e: EnergyData): string[] {
|
||||||
if (isStale(e.time)) return Object.keys(NODES);
|
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 }) {
|
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} />
|
<Flow from="battery" to="house" watts={e.batteryPowerW} />
|
||||||
<Node id="pv" dim={dim("pv")} value={power(e.pvPowerW)} />
|
<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="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} />
|
<Node id="battery" dim={dim("battery")} onOpen={onBattery} value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
|
||||||
</svg>
|
</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.
|
// 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 }) {
|
export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; link?: boolean }) {
|
||||||
const today = e?.today;
|
const today = e?.today;
|
||||||
const load = today?.loadKwh ?? null;
|
const house = today?.houseKwh ?? null;
|
||||||
const bought = today?.gridBoughtKwh ?? 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 = [
|
const parts = [
|
||||||
{ name: "Z PV i baterii", value: own ?? 0, colour: "#10b981" },
|
{ name: "Z PV i baterii", value: own ?? 0, colour: "#10b981" },
|
||||||
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
|
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
|
||||||
@@ -159,7 +159,7 @@ export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; li
|
|||||||
</Pie>
|
</Pie>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
<div className="absolute inset-0 grid place-items-center text-center">
|
<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>
|
</div>
|
||||||
<div className="min-w-44 flex-1 text-sm">
|
<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. */
|
/** 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 {
|
export function deviceSummary(d: EnergyDetailsDevice, e: EnergyData | undefined): string {
|
||||||
if (d.id === "deye") {
|
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(" · ");
|
return [`Bateria ${percent(reading(d, "Bateria", "Naładowanie")[0]?.value)}`, `PV ${power(pv)}`].map(unbreakable).join(" · ");
|
||||||
}
|
}
|
||||||
if (d.id === "fronius") return `PV ${power(reading(d, "Teraz", "Moc PV")[0]?.value)}`;
|
if (d.id === "fronius") return `PV ${power(reading(d, "Teraz", "Moc PV")[0]?.value)}`;
|
||||||
@@ -240,7 +241,7 @@ function Now({ onBattery }: { onBattery: () => void }) {
|
|||||||
<EnergyToday e={e} />
|
<EnergyToday e={e} />
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3 lg:grid-cols-4">
|
<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("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("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)} />
|
<Stat dim={quiet.includes("battery")} icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { example, serve } from "./fixtures";
|
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.
|
// A fresh module per test: the poller keeps its answers between screens, not between tests.
|
||||||
const historyScreen = async () => (await import("./History")).History;
|
const historyScreen = async () => (await import("./History")).History;
|
||||||
@@ -14,7 +14,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function serveStats(stats: Stats = example("stats")) {
|
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) {
|
async function open(stats?: Stats) {
|
||||||
@@ -30,7 +30,7 @@ describe("Historia views", () => {
|
|||||||
serveStats();
|
serveStats();
|
||||||
const History = await historyScreen();
|
const History = await historyScreen();
|
||||||
render(<History />);
|
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");
|
expect(tab("Wykresy")).toHaveAttribute("aria-selected", "true");
|
||||||
|
|
||||||
await userEvent.click(tab("Statystyki"));
|
await userEvent.click(tab("Statystyki"));
|
||||||
@@ -50,25 +50,14 @@ describe("Historia views", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Statystyki", () => {
|
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();
|
await open();
|
||||||
|
|
||||||
expect(await screen.findByText("Załączenia sprężarki, co najmniej")).toBeInTheDocument();
|
expect(await screen.findByText("Załączenia sprężarki, co najmniej")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("0,2 h").length).toBeGreaterThan(0);
|
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();
|
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 () => {
|
it("asks for 30 days when chosen", async () => {
|
||||||
const fetch = await open();
|
const fetch = await open();
|
||||||
await screen.findByText("Sprężarka");
|
await screen.findByText("Sprężarka");
|
||||||
@@ -94,4 +83,37 @@ describe("Statystyki", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText("Za mało danych w tym okresie.")).toBeInTheDocument();
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { number } from "./format";
|
import { number } from "./format";
|
||||||
import { usePoll } from "./poll";
|
import { usePoll } from "./poll";
|
||||||
|
import { Program } from "./Program";
|
||||||
import { Stats } from "./Stats";
|
import { Stats } from "./Stats";
|
||||||
import { RangeToggle, SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
|
import { RangeToggle, SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
|
||||||
import { TimeChart, toRows, type Range } from "./TimeChart";
|
import { TimeChart, toRows, type Range } from "./TimeChart";
|
||||||
@@ -18,7 +19,7 @@ const SERIES = {
|
|||||||
pump_outlet_temp: ["Wyjście pompy ciepła", "#f26b1d"],
|
pump_outlet_temp: ["Wyjście pompy ciepła", "#f26b1d"],
|
||||||
grid_power: ["Moc z sieci (+ pobór, − oddawanie)", "#3b82f6"],
|
grid_power: ["Moc z sieci (+ pobór, − oddawanie)", "#3b82f6"],
|
||||||
pv_power: ["Produkcja PV", "#f59e0b"],
|
pv_power: ["Produkcja PV", "#f59e0b"],
|
||||||
load_power: ["Zużycie domu", "#10b981"],
|
load_power: ["Wyjście zapasowe falownika", "#10b981"],
|
||||||
battery_soc: ["Bateria (naładowanie)", "#8b5cf6"],
|
battery_soc: ["Bateria (naładowanie)", "#8b5cf6"],
|
||||||
} as const;
|
} as const;
|
||||||
type SeriesId = keyof typeof SERIES;
|
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() {
|
export function History() {
|
||||||
const [view, show] = useSubView("history", VIEWS);
|
const [view, show] = useSubView("history", VIEWS);
|
||||||
@@ -75,7 +76,7 @@ export function History() {
|
|||||||
<PageTitle title="Historia" subtitle="Dane z bazy pomiarów" />
|
<PageTitle title="Historia" subtitle="Dane z bazy pomiarów" />
|
||||||
<SubViewSwitch screen="history" label="Widok historii" views={VIEWS} view={view} onSelect={show} />
|
<SubViewSwitch screen="history" label="Widok historii" views={VIEWS} view={view} onSelect={show} />
|
||||||
<SubViewPanel screen="history" view={view}>
|
<SubViewPanel screen="history" view={view}>
|
||||||
{view === "charts" ? <Charts /> : <Stats />}
|
{view === "charts" ? <Charts /> : view === "stats" ? <Stats /> : <Program />}
|
||||||
</SubViewPanel>
|
</SubViewPanel>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -143,4 +143,35 @@ describe("Overview", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText(/Dane są nieaktualne/)).toBeInTheDocument();
|
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$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
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 { Icon, type IconName } from "./icons";
|
||||||
import { flowing } from "./flow";
|
import { flowing } from "./flow";
|
||||||
import { usePoll } from "./poll";
|
import { usePoll } from "./poll";
|
||||||
@@ -58,13 +58,23 @@ const gridNote = (watts: number | null | undefined) =>
|
|||||||
watts == null ? "brak danych o sieci"
|
watts == null ? "brak danych o sieci"
|
||||||
: flowing(watts) ? `${gridText(watts)} ${power(Math.abs(watts))}` : gridText(watts);
|
: 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.
|
// 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 }) {
|
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 home = pi?.temperature ?? hp.temperatures.indoor;
|
||||||
const humidity = pi?.temperature == null ? null : pi.humidity;
|
const humidity = pi?.temperature == null ? null : pi.humidity;
|
||||||
return (
|
return (
|
||||||
<section aria-label="Teraz" className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 lg:grid-cols-4">
|
<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)}
|
<Figure icon="home" label="W domu" value={celsius(home)}
|
||||||
note={humidity == null ? "czujnik pompy" : `wilgotność ${number(humidity)} %`} />
|
note={humidity == null ? "czujnik pompy" : `wilgotność ${number(humidity)} %`} />
|
||||||
<Figure icon="thermometer" label="Na zewnątrz" value={celsius(hp.temperatures.outdoor)}
|
<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 }) {
|
function DeviceTiles({ hp }: { hp: HeatPump }) {
|
||||||
return (
|
return (
|
||||||
<section aria-label="Stan urządzeń" className="grid gap-3 sm:grid-cols-2 sm:gap-4">
|
<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} %`}
|
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
|
||||||
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)} />
|
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)} />
|
||||||
<Tile icon="tank" title="Zbiornik CWU" detail={`cyrkulacja ${onOff(hp.pumps.circulation, "pracuje", "stoi")}`}
|
<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 compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
|
||||||
const items: [string, boolean | null, string][] = [
|
const items: [string, boolean | null, string][] = [
|
||||||
["Sprężarka", compressor, compressor ? percent(hp.compressorPercent) : "stoi"],
|
["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] => {
|
...Object.entries(PUMP_NAMES).map(([key, name]): [string, boolean | null, string] => {
|
||||||
const on = hp.pumps[key as keyof typeof PUMP_NAMES];
|
const on = hp.pumps[key as keyof typeof PUMP_NAMES];
|
||||||
return [name, on, onOff(on, "pracuje", "stoi")];
|
return [name, on, onOff(on, "pracuje", "stoi")];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { answer, example, serve } from "./fixtures";
|
||||||
|
|
||||||
|
const button = () => screen.getByRole("button", { name: "Odśwież" });
|
||||||
|
const status = () => screen.getByRole("status");
|
||||||
|
const noStatus = () => screen.queryByRole("status");
|
||||||
|
|
||||||
|
// The button and a poller that shows api/heat-pump, as a screen would.
|
||||||
|
async function shown(onPost: () => Promise<Response>) {
|
||||||
|
const fetch = serve({ "api/heat-pump": example("heat-pump") }, onPost);
|
||||||
|
const { RefreshButton } = await import("./Refresh");
|
||||||
|
const { usePoll } = await import("./poll");
|
||||||
|
function Screen() {
|
||||||
|
usePoll("api/heat-pump");
|
||||||
|
return <RefreshButton />;
|
||||||
|
}
|
||||||
|
render(<Screen />);
|
||||||
|
await act(async () => {});
|
||||||
|
return fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method === "POST");
|
||||||
|
const gets = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method !== "POST");
|
||||||
|
|
||||||
|
describe("RefreshButton", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "setInterval", "Date"] });
|
||||||
|
vi.setSystemTime(new Date("2026-10-24T10:00:00Z"));
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("asks once, shows the progress, then fetches again and says when it updated", async () => {
|
||||||
|
const fetch = await shown(() => answer({ requested: ["deye"], skipped: [] }));
|
||||||
|
const before = gets(fetch).length;
|
||||||
|
|
||||||
|
fireEvent.click(button());
|
||||||
|
fireEvent.click(button());
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(posts(fetch)).toHaveLength(1);
|
||||||
|
expect(posts(fetch)[0][1]!.headers).toMatchObject({ "X-Requested-With": "home-pump" });
|
||||||
|
expect(status()).toHaveTextContent("Odświeżanie…");
|
||||||
|
expect(button()).toBeDisabled();
|
||||||
|
expect(gets(fetch)).toHaveLength(before);
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(5000));
|
||||||
|
|
||||||
|
expect(gets(fetch)).toHaveLength(before + 1);
|
||||||
|
expect(status()).toHaveTextContent("Zaktualizowano 12:00:05");
|
||||||
|
expect(button()).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says the data is fresh when every device was skipped and waits out the cooldown", async () => {
|
||||||
|
const fetch = await shown(() => answer({
|
||||||
|
requested: [],
|
||||||
|
skipped: [{ device: "heat-pump", retryInSeconds: 41 }, { device: "deye", retryInSeconds: 30 }],
|
||||||
|
}));
|
||||||
|
const before = gets(fetch).length;
|
||||||
|
|
||||||
|
fireEvent.click(button());
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(status()).toHaveTextContent("Dane są świeże – spróbuj za 30 s");
|
||||||
|
expect(button()).toBeDisabled();
|
||||||
|
expect(gets(fetch)).toHaveLength(before);
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
||||||
|
expect(status()).toHaveTextContent("Dane są świeże – spróbuj za 20 s");
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(20_000));
|
||||||
|
expect(noStatus()).toBeNull();
|
||||||
|
expect(button()).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a short error when the backend has no gateway, and allows another try", async () => {
|
||||||
|
const fetch = await shown(() => answer({ error: "gateway unreachable" }, 503));
|
||||||
|
|
||||||
|
fireEvent.click(button());
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(status()).toHaveTextContent("Nie udało się odświeżyć danych");
|
||||||
|
expect(button()).toBeEnabled();
|
||||||
|
fireEvent.click(button());
|
||||||
|
await act(async () => {});
|
||||||
|
expect(posts(fetch)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when the network is down", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
|
||||||
|
const { RefreshButton } = await import("./Refresh");
|
||||||
|
render(<RefreshButton />);
|
||||||
|
|
||||||
|
fireEvent.click(button());
|
||||||
|
await act(async () => {});
|
||||||
|
|
||||||
|
expect(status()).toHaveTextContent("Brak połączenia z siecią");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sits in the heading row of a screen, next to the title", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
|
||||||
|
const { PageTitle } = await import("./ui");
|
||||||
|
render(<PageTitle title="Stan" />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("banner")).toContainElement(button());
|
||||||
|
expect(button()).toHaveTextContent("Odśwież");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { postRefresh, WriteError } from "./api";
|
||||||
|
import { hourWithSeconds } from "./format";
|
||||||
|
import { Icon } from "./icons";
|
||||||
|
import { refreshAll } from "./poll";
|
||||||
|
|
||||||
|
// The gateway polls the devices about 3 s after the request; the answers are fetched after this.
|
||||||
|
export const SETTLE_MS = 5000;
|
||||||
|
|
||||||
|
type State =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "busy" }
|
||||||
|
| { kind: "done"; at: Date }
|
||||||
|
| { kind: "fresh"; until: number }
|
||||||
|
| { kind: "error"; text: string };
|
||||||
|
|
||||||
|
const failure = (error: unknown) =>
|
||||||
|
error instanceof WriteError && error.status === 0 ? "Brak połączenia z siecią" : "Nie udało się odświeżyć danych";
|
||||||
|
|
||||||
|
// Asks the gateway for current readings; it skips devices it polled less than a minute ago.
|
||||||
|
export function RefreshButton() {
|
||||||
|
const [state, setState] = useState<State>({ kind: "idle" });
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
const waiting = state.kind === "fresh" ? Math.ceil((state.until - now) / 1000) : 0;
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.kind !== "fresh") return;
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [state.kind]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.kind === "fresh" && waiting <= 0) setState({ kind: "idle" });
|
||||||
|
}, [state.kind, waiting]);
|
||||||
|
|
||||||
|
const tap = async () => {
|
||||||
|
setState({ kind: "busy" });
|
||||||
|
try {
|
||||||
|
const { requested, skipped } = await postRefresh();
|
||||||
|
if (requested.length > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
||||||
|
refreshAll();
|
||||||
|
setState({ kind: "done", at: new Date() });
|
||||||
|
} else {
|
||||||
|
const retry = Math.min(...skipped.map((s) => s.retryInSeconds));
|
||||||
|
setNow(Date.now());
|
||||||
|
setState({ kind: "fresh", until: Date.now() + retry * 1000 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setState({ kind: "error", text: failure(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = {
|
||||||
|
idle: "", busy: "Odświeżanie…", error: state.kind === "error" ? state.text : "",
|
||||||
|
done: state.kind === "done" ? `Zaktualizowano ${hourWithSeconds(state.at)}` : "",
|
||||||
|
fresh: `Dane są świeże – spróbuj za ${waiting} s`,
|
||||||
|
}[state.kind];
|
||||||
|
const busy = state.kind === "busy";
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<button onClick={tap} disabled={busy || state.kind === "fresh"}
|
||||||
|
className="flex min-h-11 items-center justify-center gap-2 rounded-xl border-2 border-brand bg-panel px-3 font-bold text-brand transition active:scale-[0.98] disabled:opacity-40">
|
||||||
|
<Icon name="refresh" className={`size-5 ${busy ? "animate-spin" : ""}`} />
|
||||||
|
Odśwież
|
||||||
|
</button>
|
||||||
|
{text && <p role="status" className={`max-w-44 text-right text-xs ${state.kind === "error" ? "text-bad" : "text-soft"}`}>{text}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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="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} />
|
<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)" />}
|
{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 */}
|
{/* Warmth rising from the floor while the CO pump runs */}
|
||||||
{co && [960, 1080, 1200, 1300].map((x, i) => (
|
{co && [960, 1080, 1200, 1300].map((x, i) => (
|
||||||
|
|||||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user