Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
458472ff76 | ||
|
|
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 | ||
|
|
655e84be8d | ||
|
|
4cb8bfd27b | ||
|
|
bdea2a177d | ||
|
|
d8b06e2a89 | ||
|
|
1405695da5 | ||
|
|
ea2862ae04 | ||
|
|
317950cb75 | ||
|
|
8423d723a1 | ||
|
|
cb93f24365 | ||
|
|
f181d0bbe3 | ||
|
|
f57c3adc58 | ||
|
|
56a457085e | ||
|
|
6dcbb5b8e4 | ||
|
|
ace189f5e2 | ||
|
|
d356fb2a2c | ||
|
|
0fb139192d | ||
|
|
40823683be | ||
|
|
1f43987db5 | ||
|
|
e88c28899f | ||
|
|
d5ab64aa61 | ||
|
|
01c5e1efe5 | ||
|
|
0819ed8261 | ||
|
|
338befaffa | ||
|
|
378f4f6623 | ||
|
|
82670eb4a1 | ||
|
|
d07be384a1 | ||
|
|
860269a353 | ||
|
|
e4481eeeae | ||
|
|
e1e50eb7c2 | ||
|
|
c7d88e6c0b | ||
|
|
186c1984c0 | ||
|
|
83d9e1ecfc | ||
|
|
cbf507fa18 | ||
|
|
a99f2b677a | ||
|
|
928915d059 | ||
|
|
c0c46973e8 | ||
|
|
64e1e6ffec |
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
|
||||
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:
|
||||
`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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
on:
|
||||
|
||||
@@ -13,8 +13,20 @@ env:
|
||||
|
||||
jobs:
|
||||
images:
|
||||
name: Images
|
||||
name: Image (${{ matrix.part }})
|
||||
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:
|
||||
- name: Checkout
|
||||
@@ -31,29 +43,14 @@ jobs:
|
||||
username: ${{ gitea.repository_owner }}
|
||||
# The owner's token with package write access (the secret homelab-upgrade uses too).
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
# 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.
|
||||
- name: Gateway
|
||||
# The registry cache keeps the Maven and npm layers between releases.
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: gateway
|
||||
context: ${{ matrix.part }}
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/arm64,linux/amd64
|
||||
platforms: ${{ matrix.platforms }}
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
|
||||
- name: Backend
|
||||
uses: docker/build-push-action@v6
|
||||
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
|
||||
tags: ${{ env.IMAGES }}-${{ matrix.part }}:${{ gitea.ref_name }},${{ env.IMAGES }}-${{ matrix.part }}:latest
|
||||
cache-from: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache,mode=max
|
||||
+3
-3
@@ -15,8 +15,8 @@ USER 10001:10001
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
||||
EXPOSE 8080
|
||||
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body),
|
||||
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine.
|
||||
# /api/live needs no session and does not look at the gateway, so it only tells whether the backend
|
||||
# itself serves requests. busybox wget ships with alpine.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/health" || exit 1
|
||||
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/live" || exit 1
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
+99
-7
@@ -3,11 +3,24 @@
|
||||
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
|
||||
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
|
||||
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
|
||||
`GET /api/energy` (example `src/test/resources/api/energy.json`) is the house electricity now. `housePowerW` is the whole house by balance, because the Deye `load` registers cover only its backup output (`backupOutputW`): Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, the grid being the house meter's `total_power_active` when live, else the Deye `grid_power_w`; floored at 0, null when any of these readings is missing or stale. `today.houseKwh` is Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged (Deye day counters), null when any is missing.
|
||||
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
|
||||
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
|
||||
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
|
||||
the three values are null (`pi-sensor-stale.json`).
|
||||
|
||||
`GET /api/stats?range=7d|30d` (example `src/test/resources/api/stats.json`; 400 otherwise, 502 when InfluxDB is down) is the heat pump per local
|
||||
day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` with `date`, `compressorHours`, `compressorStarts`, `cwuHours`, `coHours`,
|
||||
`avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
|
||||
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
|
||||
|
||||
`GET /api/program?days=14` (7 to 30, default 14; 400 otherwise, 502 when InfluxDB is down; example `src/test/resources/api/program.json`) is the weekly
|
||||
program as the history shows it, not read from the controller: `weekdays[]` from Monday (`day` 1) to Sunday, each with `cwu` and `room`:
|
||||
`comfort` (intervals `from`/`to`, local "HH:MM", "24:00" at midnight), `observedDays`, `agreeingDays` and `confidence` (`confirmed`: at least
|
||||
two days of that weekday agree within 5 minutes, `seen_once`, `no_data`). A boundary is the middle between the two polls where the period
|
||||
changes (`Periods`), rounded to 5 minutes; an override, ambiguous setpoints (e.g. during our writes) or a gap over 600 s drop the interval.
|
||||
Today is left out. `/api/heat-pump` has `periods` (`cwu`, `room`: `comfort`, `economy`, `unknown`, `override`; `program`, `programText`).
|
||||
|
||||
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
|
||||
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
|
||||
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
|
||||
@@ -18,13 +31,27 @@ The settings come from the gateway's slower `deye_settings` reading and are stal
|
||||
true when the gateway answers its `/battery/reserve`, i.e. it has battery commands on and `GATEWAY_TOKEN` is right; else `reserve` is null
|
||||
and `editable` false. `POST /api/battery/schedule` (examples `battery-schedule-request.json`, `battery-failed.json`) takes
|
||||
`{"enabled"?, "slots": [{"slot": 1-6, "from"?: "HH:MM", "socPct"?, "gridCharge"?}]}` and `POST /api/battery/reserve` takes `{"pct": 60}`.
|
||||
Same Origin, header and JSON rules as the other writes; `Remote-User` goes on as the requester. The gateway's verdict comes back as
|
||||
Same Origin, header and JSON rules as the other writes; the signed-in user goes on as the requester. The gateway's verdict comes back as
|
||||
`{status, message, read}`: 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon, 502 failed (`read` is what the
|
||||
inverter holds), 400 bad body, 503 gateway unreachable or without battery commands (ADR-013).
|
||||
|
||||
`GET /api/audit?range=7d` (`24h`, `7d` default, `30d`; 400 otherwise, 502 when InfluxDB fails) is the change history, example
|
||||
`src/test/resources/api/audit.json`: the gateway's audit rows of the heat pump (`Dom_Pompa.setpoint_audit`) and the battery
|
||||
(`electricity.deye_settings_audit`), read only, as `entries` newest first: `time`, `source` (`heat_pump`, `battery`), `what` (Polish label;
|
||||
the Deye registers are named in `DeyeSettingLabels`), `oldValue`/`newValue` (whole numbers, null when not recorded), `unit` (`°C`, `%`,
|
||||
`HHMM`, `0/1`), `who` (the signed-in user; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
|
||||
|
||||
`GET /api/weather` (example `src/test/resources/api/weather.json`) is the forecast of Open-Meteo for the house and the IMGW warnings that name
|
||||
Krosno (TERYT 1862 or the powiat 1807): `today`, `tomorrow`, `dayAfter` (`date`, `weatherCode` WMO, Polish `description`, `tMax`, `tMin`,
|
||||
`precipitationMm`, `sunshineHours`, `pvForecastKwh`), `now.temperature`, `warnings` (`event`, `level` 1-3, `from`, `to`, `text`; the ones that
|
||||
ended are dropped). `pvFactor` is kWh per MJ/m^2: the Deye plus Fronius energy of the past 14 complete days (from InfluxDB, daily maximum of
|
||||
`pv_today_kwh` and `energy_day_wh`) over the forecast radiation of the same days, so no tilt or azimuth is needed; `pvForecastKwh` is the factor
|
||||
times the day's radiation, and both are null with fewer than 5 days that have both values. The forecast is kept 30 min and the warnings 10 min;
|
||||
when a refresh fails the older answer is served with `stale` true. 503 only when there never was a forecast. The sources are public and need no key.
|
||||
|
||||
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||
without the two URLs.
|
||||
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`.
|
||||
Setpoint changes go only to the gateway; the signed-in user is sent as `X-Requested-By`.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
@@ -34,32 +61,97 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us
|
||||
| `GATEWAY_TIMEOUT` | `5` | seconds |
|
||||
| `INFLUX_TIMEOUT` | `10` | seconds |
|
||||
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
|
||||
| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast |
|
||||
| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count |
|
||||
| `USERS_FILE` | unset | local accounts, see Sign-in; outside the image like `PRESETS_FILE` |
|
||||
| `OIDC_ISSUER` | unset | Keycloak realm URL, e.g. `https://login.mariuszpacek.pl/realms/home`; unset turns OIDC off |
|
||||
| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | required with `OIDC_ISSUER` | the confidential client; redirect `https://pompa.mariuszpacek.pl/api/auth/oidc/callback` |
|
||||
| `OIDC_REQUIRED_ROLE` | unset | a role the user must have: realm role (`realm_access.roles`) or role of this client (`resource_access.<client>.roles`) in the ID token |
|
||||
| `SESSION_SECRET` | required with `USERS_FILE` or `OIDC_ISSUER` | at least 32 bytes signing the cookies, e.g. `openssl rand -base64 48`; changing it signs everyone out |
|
||||
| `SESSION_DAYS` | `30` | how long a session lasts |
|
||||
| `TRUSTED_PROXY_HOPS` | `1` | proxies that append to `X-Forwarded-For` before the backend (Traefik: 1), for the login throttle |
|
||||
| `AUTH_DISABLED` | unset | `true` runs without sign-in (every request is `phone`), for development; refused together with `USERS_FILE` or `OIDC_ISSUER` |
|
||||
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
|
||||
|
||||
```bash
|
||||
mvn -f backend/pom.xml clean package
|
||||
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
|
||||
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example AUTH_DISABLED=true \
|
||||
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
|
||||
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
|
||||
```
|
||||
|
||||
## Sign-in (ADR-014)
|
||||
|
||||
Without `USERS_FILE` or `OIDC_ISSUER` the backend refuses to start unless `AUTH_DISABLED=true`. The two ways in are
|
||||
independent: local accounts keep working when Keycloak is down, and the backend never waits for Keycloak at start
|
||||
(discovery and keys are fetched at the first sign-in and kept an hour). Every endpoint but those below answers
|
||||
`401 {"error":"unauthenticated"}` without a valid session; the writes keep their Origin, header and JSON rules.
|
||||
|
||||
| Endpoint | Answer |
|
||||
|---|---|
|
||||
| `GET /api/auth/config` | `{"local", "oidc", "setup"}` booleans (`auth-config.json`); `setup` is true while `USERS_FILE` has no account |
|
||||
| `POST /api/auth/setup` `{"username","password"}` | first account, once: 200 `{"user","method":"local"}` and the cookie; 409 `{"error":"setup_done"}` once any account exists; 400 `weak_password` (under 10 characters) or `invalid_username`; 404 without `USERS_FILE` |
|
||||
| `GET /api/auth/users` | session needed: `[{"username"}]` |
|
||||
| `POST /api/auth/users` `{"username","password"}` | session needed: adds a local account; 200 `{"username"}`, 409 `exists`, 400 as setup |
|
||||
| `POST /api/auth/password` `{"current","new"}` | session needed, local sessions only (403 `local_only` otherwise): 204; 401 `invalid` (counts toward the throttle), 429, 400 `weak_password` |
|
||||
| `POST /api/auth/users/{name}/delete` | session needed: 204 (removing yourself also clears your cookie); 404 `unknown_user`; 409 `last_user` |
|
||||
| `POST /api/auth/login` `{"username","password"}` | 200 `{"user","method":"local"}` and the cookie; 401 `{"error":"invalid"}`; 429 `{"error":"throttled","retryAfterSeconds"}` with `Retry-After`; 404 without `USERS_FILE` |
|
||||
| `POST /api/auth/logout` | 204, cookie cleared |
|
||||
| `GET /api/auth/me` | 200 `{"user","method"}` (`auth-me.json`; `method` is `local`, `oidc`, or `none` with `AUTH_DISABLED`) or 401 |
|
||||
| `GET /api/auth/oidc/start` | 302 to Keycloak; 404 without OIDC |
|
||||
| `GET /api/auth/oidc/callback` | 302 to `/`, or to `/#login?error=oidc_unavailable` (Keycloak down), `oidc_denied` (refused or role missing), `oidc_failed` (bad state, token or code) |
|
||||
| `GET /api/live` | 200 `ok`, for the container healthcheck and external monitors: `/api/health` needs a session now |
|
||||
|
||||
The session is a cookie `hp_session` (HttpOnly, Secure, SameSite=Lax, Path=/) signed with HMAC-SHA256 and holding user, method,
|
||||
issue time and expiry; there is no server state, so a stolen cookie lives until it expires or the secret changes; a `local` session also ends when its account
|
||||
is removed (it is checked on every request).
|
||||
The OIDC flow is authorization code with PKCE S256, state and nonce, in a 10 minute signed cookie `hp_oidc`; the ID token is
|
||||
verified here (RS256 against the JWKS key by `kid`, `iss`, `aud` and `azp`, `exp`/`iat` with 60 s skew, `nonce`).
|
||||
The user name is `preferred_username`, else `email`, else `sub`.
|
||||
|
||||
**Local accounts**: `USERS_FILE` is JSON `[{"username": "tata", "passwordHash": "pbkdf2-sha256$600000$<salt>$<hash>"}]`, a username of
|
||||
1 to 64 of `A-Za-z0-9_.@+-`, a bad file stops the start. The app edits the file: it rewrites it atomically (temp file in the same directory, fsync, rename, mode 600), so mount a **directory** (`./auth:/config/auth`, `USERS_FILE=/config/auth/users.json`) that uid 10001 owns: `chown 10001:10001 auth`. A missing file or one with no accounts is setup mode: the first visitor to the app creates the first account in the app (accepted risk, ADR-014: Traefik admits only the two home networks). Add, remove accounts and change a password in the app; each change is logged (who, what, never a password). A hash can still be made by hand, without echoing the password (asked twice on a terminal;
|
||||
a pipe reads one line):
|
||||
|
||||
```bash
|
||||
docker run --rm -it git.mariuszpacek.pl/mariusz.pacek/home-pump-backend:latest hash-password
|
||||
java -jar backend/target/home-pump-backend-1.0.0.jar hash-password
|
||||
```
|
||||
|
||||
Five login attempts in 15 minutes block that username and, separately, that client address (429, `Retry-After`; an attempt is
|
||||
counted before the password is checked, so parallel guesses cannot outrun it, and a success clears the username); a blocked
|
||||
username is refused even with the right password. At most 4 passwords are hashed at once, more get 429 at once. An unknown username costs one password hash like a wrong password.
|
||||
The address is the entry `TRUSTED_PROXY_HOPS` from the right of `X-Forwarded-For` (Traefik appends the address it saw;
|
||||
what a client put in front is ignored); without it the socket's address.
|
||||
|
||||
**Keycloak client** in the realm of `OIDC_ISSUER`: Client authentication on (confidential), only Standard flow, PKCE method
|
||||
`S256`, valid redirect URI `https://pompa.mariuszpacek.pl/api/auth/oidc/callback`, web origin `https://pompa.mariuszpacek.pl`.
|
||||
Its secret is `OIDC_CLIENT_SECRET`. Without `OIDC_REQUIRED_ROLE`, every user of the realm can sign in; the backend logs a warning at start. For `OIDC_REQUIRED_ROLE`, the client scope `roles` must put the roles into the ID token
|
||||
(its realm and client role mappers: "Add to ID token" on).
|
||||
|
||||
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
|
||||
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
|
||||
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
|
||||
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
|
||||
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
|
||||
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
|
||||
(from `/commands`), checked before anything is sent. A timed preset (built-in "Ciepła woda teraz") also takes optional
|
||||
`"hours"` (example `preset-cwu-now-request.json`): a whole number from 1 to 4 replacing its duration; anything else, or
|
||||
hours for a held preset, is 400 `{"error":"invalid_hours"}`. A held value that is not below the current one is 422. The gateway holds those changes until then without
|
||||
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
|
||||
|
||||
`POST /api/refresh` (same Origin, header and JSON rules; send `{}`) is the "Odśwież" button: it asks the gateway for one
|
||||
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
|
||||
pending change of that setpoint now and answers with the gateway's verdict, example
|
||||
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
|
||||
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
|
||||
pending), 503 gateway unreachable.
|
||||
|
||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
|
||||
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
|
||||
rules (ADR-009).
|
||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend, so Traefik's
|
||||
access rules (ADR-009) stay in front of the sign-in.
|
||||
|
||||
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
|
||||
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
|
||||
@@ -9,6 +9,13 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: Europe/Warsaw
|
||||
# Sign-in (README): the accounts directory, writable by uid 10001 (chown 10001:10001 auth); the app rewrites users.json in it. In .env, for example:
|
||||
# USERS_FILE=/config/auth/users.json SESSION_SECRET=<openssl rand -base64 48>
|
||||
# OIDC_ISSUER=https://login.mariuszpacek.pl/realms/home OIDC_CLIENT_ID=home-pump
|
||||
# OIDC_CLIENT_SECRET=<from Keycloak> OIDC_REQUIRED_ROLE=family
|
||||
# OIDC_REDIRECT_URI=https://pompa.mariuszpacek.pl/api/auth/oidc/callback
|
||||
volumes:
|
||||
- ./auth:/config/auth
|
||||
networks: [home-pump]
|
||||
|
||||
networks:
|
||||
|
||||
@@ -1,39 +1,56 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.adapters.gateway.GatewayHttpClient;
|
||||
import home.backend.adapters.gateway.GatewayHttpCommands;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import home.backend.adapters.influx.InfluxAuditRepository;
|
||||
import home.backend.adapters.influx.InfluxHistoryRepository;
|
||||
import home.backend.adapters.influx.InfluxPvEnergy;
|
||||
import home.backend.adapters.influx.InfluxSettings;
|
||||
import home.backend.adapters.weather.ImgwClient;
|
||||
import home.backend.adapters.weather.OpenMeteoClient;
|
||||
import home.backend.adapters.weather.WeatherSettings;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.ProgramService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.LeaseRenewer;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.application.WeatherService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Wires the adapters and services together; the only place that knows all of them. */
|
||||
final class Backend {
|
||||
|
||||
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
|
||||
/** Warnings are optional, so a slow IMGW must not hold the forecast. */
|
||||
private static final Duration WARNINGS_TIMEOUT = Duration.ofSeconds(3);
|
||||
|
||||
private Backend() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the port but does not start serving.
|
||||
*
|
||||
* @throws IllegalArgumentException when PRESETS_FILE is unusable
|
||||
* @throws IllegalArgumentException when PRESETS_FILE or USERS_FILE is unusable
|
||||
*/
|
||||
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
|
||||
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
|
||||
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
|
||||
var influxSettings = new InfluxSettings(settings.influxUrl(), settings.influxTimeout());
|
||||
var influx = new InfluxHistoryRepository(influxSettings);
|
||||
var commands = commands(settings);
|
||||
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
|
||||
return new ApiServer(settings.port(),
|
||||
@@ -42,7 +59,19 @@ final class Backend {
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
|
||||
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, commands, clock));
|
||||
new BatteryService(gateway, commands, clock),
|
||||
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
|
||||
new ProgramService(influx, clock),
|
||||
weather(influxSettings, clock), commands,
|
||||
Authentication.create(settings.auth(), clock));
|
||||
}
|
||||
|
||||
private static WeatherService weather(InfluxSettings influx, Clock clock) {
|
||||
var place = WeatherSettings.fromEnvironment(System.getenv());
|
||||
return new WeatherService(
|
||||
new OpenMeteoClient(URI.create("https://api.open-meteo.com"), place, WEATHER_TIMEOUT),
|
||||
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WARNINGS_TIMEOUT),
|
||||
new InfluxPvEnergy(influx), clock);
|
||||
}
|
||||
|
||||
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
|
||||
|
||||
@@ -2,6 +2,8 @@ package home.backend;
|
||||
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
|
||||
import home.backend.adapters.auth.PasswordHashCli;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
|
||||
@@ -11,6 +13,9 @@ public final class Main {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
if (args.length == 1 && args[0].equals("hash-password")) {
|
||||
System.exit(PasswordHashCli.run(System.console(), System.in, System.out, System.err));
|
||||
}
|
||||
Settings settings;
|
||||
ApiServer server;
|
||||
try {
|
||||
@@ -21,6 +26,7 @@ public final class Main {
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
settings.auth().warnings().forEach(w -> System.err.println("home-pump-backend: WARNING: " + w));
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
if (settings.gatewayToken() == null) {
|
||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.auth.AuthSettings;
|
||||
import home.backend.adapters.auth.OidcSettings;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
@@ -8,7 +12,7 @@ import java.util.Optional;
|
||||
|
||||
/** Twelve-factor settings: everything comes from environment variables. */
|
||||
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
|
||||
String gatewayToken, Path presetsFile) {
|
||||
String gatewayToken, Path presetsFile, AuthSettings auth) {
|
||||
|
||||
public static Settings fromEnvironment(Map<String, String> env) {
|
||||
return new Settings(
|
||||
@@ -18,7 +22,44 @@ public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gateway
|
||||
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
|
||||
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
|
||||
optional(env, "GATEWAY_TOKEN"),
|
||||
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null));
|
||||
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null),
|
||||
auth(env));
|
||||
}
|
||||
|
||||
private static final int MIN_SECRET_BYTES = 32;
|
||||
|
||||
/** Refuses to start open by accident: sign-in is configured, or AUTH_DISABLED=true says it is meant. */
|
||||
private static AuthSettings auth(Map<String, String> env) {
|
||||
Path usersFile = Optional.ofNullable(optional(env, "USERS_FILE")).map(Path::of).orElse(null);
|
||||
OidcSettings oidc = optional(env, "OIDC_ISSUER") == null ? null : oidc(env);
|
||||
String secret = optional(env, "SESSION_SECRET");
|
||||
boolean disabled = "true".equals(optional(env, "AUTH_DISABLED"));
|
||||
int days = (int) number(env, "SESSION_DAYS", 30, 1, 365);
|
||||
int hops = (int) number(env, "TRUSTED_PROXY_HOPS", 1, 1, 10);
|
||||
boolean configured = usersFile != null || oidc != null;
|
||||
if (disabled && configured) {
|
||||
throw new IllegalArgumentException("AUTH_DISABLED=true contradicts USERS_FILE and OIDC_ISSUER: unset one");
|
||||
}
|
||||
if (!disabled && !configured) {
|
||||
throw new IllegalArgumentException("no sign-in configured: set USERS_FILE and/or OIDC_ISSUER, "
|
||||
+ "or AUTH_DISABLED=true for development only");
|
||||
}
|
||||
if (configured && (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < MIN_SECRET_BYTES)) {
|
||||
throw new IllegalArgumentException("SESSION_SECRET is required and must be at least " + MIN_SECRET_BYTES
|
||||
+ " bytes, e.g. from: openssl rand -base64 48");
|
||||
}
|
||||
return new AuthSettings(usersFile, oidc, secret, days, disabled, hops);
|
||||
}
|
||||
|
||||
private static OidcSettings oidc(Map<String, String> env) {
|
||||
for (String name : new String[] {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI"}) {
|
||||
if (optional(env, name) == null) {
|
||||
throw new IllegalArgumentException(name + " is required when OIDC_ISSUER is set");
|
||||
}
|
||||
}
|
||||
return new OidcSettings(URI.create(optional(env, "OIDC_ISSUER")), optional(env, "OIDC_CLIENT_ID"),
|
||||
optional(env, "OIDC_CLIENT_SECRET"), URI.create(optional(env, "OIDC_REDIRECT_URI")),
|
||||
optional(env, "OIDC_REQUIRED_ROLE"));
|
||||
}
|
||||
|
||||
/** Null when unset or blank. */
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* How users sign in. At least one of usersFile and oidc, or disabled; the secret signs the cookies.
|
||||
* proxyHops is how many proxies append to X-Forwarded-For before the backend (Traefik: 1).
|
||||
*/
|
||||
public record AuthSettings(Path usersFile, OidcSettings oidc, String sessionSecret, int sessionDays,
|
||||
boolean disabled, int proxyHops) {
|
||||
|
||||
/** Things worth saying at startup. */
|
||||
public List<String> warnings() {
|
||||
if (oidc != null && oidc.requiredRole() == null) {
|
||||
return List.of("OIDC_REQUIRED_ROLE is not set: every user of the Keycloak realm can sign in");
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
/** Who may use the API: local accounts and OIDC, independent of each other, both ending in a session cookie. */
|
||||
public final class Authentication {
|
||||
|
||||
public static final String LOCAL = "local";
|
||||
|
||||
public enum Status { OK, INVALID, THROTTLED }
|
||||
|
||||
public record Login(Status status, long retryAfterSeconds) {
|
||||
}
|
||||
|
||||
private static final int MAX_FAILURES = 5;
|
||||
private static final Duration THROTTLE_WINDOW = Duration.ofMinutes(15);
|
||||
/** Password hashing is slow on purpose; a flood must not queue up behind it. */
|
||||
private static final int PARALLEL_HASHES = 4;
|
||||
private static final int MIN_PASSWORD = 10;
|
||||
private static final long BUSY_RETRY_SECONDS = 1;
|
||||
|
||||
private final Sessions sessions;
|
||||
private final UserStore users;
|
||||
private final LoginThrottle throttle;
|
||||
private final OidcFlow oidc;
|
||||
private final int proxyHops;
|
||||
private final Semaphore hashing = new Semaphore(PARALLEL_HASHES);
|
||||
|
||||
private Authentication(Sessions sessions, UserStore users, LoginThrottle throttle, OidcFlow oidc, int proxyHops) {
|
||||
this.sessions = sessions;
|
||||
this.users = users;
|
||||
this.throttle = throttle;
|
||||
this.oidc = oidc;
|
||||
this.proxyHops = proxyHops;
|
||||
}
|
||||
|
||||
/** @throws IllegalArgumentException when USERS_FILE is unusable */
|
||||
public static Authentication create(AuthSettings settings, Clock clock) {
|
||||
if (settings.disabled()) {
|
||||
return new Authentication(null, null, null, null, settings.proxyHops());
|
||||
}
|
||||
var tokens = new SignedTokens(settings.sessionSecret().getBytes(StandardCharsets.UTF_8), clock);
|
||||
return new Authentication(new Sessions(tokens, clock, Duration.ofDays(settings.sessionDays())),
|
||||
settings.usersFile() == null ? null : UserStore.load(settings.usersFile()),
|
||||
new LoginThrottle(clock, MAX_FAILURES, THROTTLE_WINDOW),
|
||||
settings.oidc() == null ? null : new OidcFlow(settings.oidc(), tokens, clock),
|
||||
settings.proxyHops());
|
||||
}
|
||||
|
||||
/** For development and tests: every request is the user "phone". */
|
||||
public static Authentication disabled() {
|
||||
return new Authentication(null, null, null, null, 1);
|
||||
}
|
||||
|
||||
public boolean localEnabled() {
|
||||
return users != null;
|
||||
}
|
||||
|
||||
public boolean oidcEnabled() {
|
||||
return oidc != null;
|
||||
}
|
||||
|
||||
public OidcFlow oidc() {
|
||||
return oidc;
|
||||
}
|
||||
|
||||
public int proxyHops() {
|
||||
return proxyHops;
|
||||
}
|
||||
|
||||
public Optional<Session> session(String cookieHeader) {
|
||||
if (sessions == null) {
|
||||
return Optional.of(Session.OPEN);
|
||||
}
|
||||
// A removed account loses its sessions at once; OIDC sessions end with their expiry.
|
||||
return sessions.read(cookieHeader).filter(s -> !LOCAL.equals(s.method()) || users != null && users.exists(s.user()));
|
||||
}
|
||||
|
||||
/** Checks the password unless the user or the address is blocked; a blocked one learns nothing about it. */
|
||||
public Login login(String username, char[] password, String clientIp) {
|
||||
String userKey = "user:" + username.toLowerCase(Locale.ROOT);
|
||||
String ipKey = "ip:" + clientIp;
|
||||
long wait = throttle.tryAcquire(userKey, ipKey);
|
||||
if (wait > 0) {
|
||||
return new Login(Status.THROTTLED, wait);
|
||||
}
|
||||
if (!hashing.tryAcquire()) {
|
||||
throttle.release(userKey, ipKey);
|
||||
return new Login(Status.THROTTLED, BUSY_RETRY_SECONDS);
|
||||
}
|
||||
try {
|
||||
if (users.authenticate(username, password)) {
|
||||
throttle.cleared(userKey);
|
||||
throttle.release(ipKey);
|
||||
return new Login(Status.OK, 0);
|
||||
}
|
||||
return new Login(Status.INVALID, 0);
|
||||
} finally {
|
||||
hashing.release();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean setupNeeded() {
|
||||
return users != null && users.isEmpty();
|
||||
}
|
||||
|
||||
public static boolean strongEnough(String password) {
|
||||
return password.length() >= MIN_PASSWORD;
|
||||
}
|
||||
|
||||
/** Creates the first account, once: OK for the winner, INVALID (setup is done) for everyone after. */
|
||||
public Login setup(String username, char[] password, String clientIp) {
|
||||
String ipKey = "ip:" + clientIp;
|
||||
long wait = throttle.tryAcquire(ipKey);
|
||||
if (wait > 0) {
|
||||
return new Login(Status.THROTTLED, wait);
|
||||
}
|
||||
if (!users.addFirst(username, PasswordHash.create(password))) {
|
||||
return new Login(Status.INVALID, 0);
|
||||
}
|
||||
System.err.println("home-pump-backend: setup created the first account " + username);
|
||||
throttle.release(ipKey);
|
||||
return new Login(Status.OK, 0);
|
||||
}
|
||||
|
||||
public java.util.List<String> usernames() {
|
||||
return users.usernames();
|
||||
}
|
||||
|
||||
/** @return false when the name is taken */
|
||||
public boolean addUser(String by, String username, char[] password) {
|
||||
boolean added = users.add(username, PasswordHash.create(password));
|
||||
System.err.println("home-pump-backend: " + by + " added account " + username + (added ? "" : " (refused, exists)"));
|
||||
return added;
|
||||
}
|
||||
|
||||
/** Checks the current password like a login does (throttled), then sets the new one. */
|
||||
public Login changePassword(String username, char[] current, char[] next, String clientIp) {
|
||||
Login login = login(username, current, clientIp);
|
||||
if (login.status() == Status.OK) {
|
||||
users.changePassword(username, PasswordHash.create(next));
|
||||
System.err.println("home-pump-backend: " + username + " changed their password");
|
||||
}
|
||||
return login;
|
||||
}
|
||||
|
||||
public UserStore.Removal removeUser(String by, String username) {
|
||||
var removal = users.remove(username);
|
||||
System.err.println("home-pump-backend: " + by + " removed account " + username + ": " + removal);
|
||||
return removal;
|
||||
}
|
||||
|
||||
/** The Set-Cookie header value that signs the user in. */
|
||||
public String sessionCookie(String user, String method) {
|
||||
return sessions.setCookie(user, method);
|
||||
}
|
||||
|
||||
public String clearedSessionCookie() {
|
||||
return sessions == null ? "" : sessions.clearCookie();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
/** Unpadded base64url, as tokens and PKCE use it. */
|
||||
final class Base64Url {
|
||||
|
||||
private Base64Url() {
|
||||
}
|
||||
|
||||
static String encode(byte[] bytes) {
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
static byte[] decode(String text) {
|
||||
return Base64.getUrlDecoder().decode(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Reads the Cookie header and builds Set-Cookie headers with the flags every cookie here carries. */
|
||||
final class Cookies {
|
||||
|
||||
private Cookies() {
|
||||
}
|
||||
|
||||
static Optional<String> read(String header, String name) {
|
||||
if (header == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
for (String pair : header.split(";")) {
|
||||
String[] kv = pair.trim().split("=", 2);
|
||||
if (kv.length == 2 && kv[0].equals(name)) {
|
||||
return Optional.of(kv[1]);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
static String set(String name, String value, Duration maxAge, String path) {
|
||||
return name + "=" + value + "; Max-Age=" + maxAge.toSeconds() + attributes(path);
|
||||
}
|
||||
|
||||
static String clear(String name, String path) {
|
||||
return name + "=; Max-Age=0" + attributes(path);
|
||||
}
|
||||
|
||||
private static String attributes(String path) {
|
||||
return "; Path=" + path + "; HttpOnly; Secure; SameSite=Lax";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
|
||||
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||
|
||||
/** Checks an ID token with the JDK alone: RS256 signature, issuer, audience, times, nonce and the required role. */
|
||||
final class IdTokenVerifier {
|
||||
|
||||
/** The signing keys of the identity provider. */
|
||||
interface Keys {
|
||||
PublicKey key(String kid) throws OidcException;
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Duration SKEW = Duration.ofSeconds(60);
|
||||
|
||||
private final OidcSettings settings;
|
||||
private final Clock clock;
|
||||
|
||||
IdTokenVerifier(OidcSettings settings, Clock clock) {
|
||||
this.settings = settings;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** @return the user name of the token */
|
||||
String verify(String idToken, String nonce, Keys keys) throws OidcException {
|
||||
String[] parts = idToken.split("\\.", -1);
|
||||
if (parts.length != 3) {
|
||||
throw new OidcException(FAILED, "the ID token is not a JWT");
|
||||
}
|
||||
JsonNode header = json(parts[0]);
|
||||
if (!"RS256".equals(header.path("alg").asText())) {
|
||||
throw new OidcException(FAILED, "the ID token is not signed with RS256");
|
||||
}
|
||||
verifySignature(parts[0] + "." + parts[1], parts[2], keys.key(header.path("kid").asText()));
|
||||
JsonNode claims = json(parts[1]);
|
||||
checkClaims(claims, nonce);
|
||||
checkRole(claims);
|
||||
return userName(claims);
|
||||
}
|
||||
|
||||
private void checkClaims(JsonNode claims, String nonce) throws OidcException {
|
||||
long now = clock.instant().getEpochSecond();
|
||||
if (!settings.issuerBase().equals(claims.path("iss").asText("").replaceAll("/+$", ""))) {
|
||||
throw new OidcException(FAILED, "wrong issuer");
|
||||
}
|
||||
if (!audiences(claims).contains(settings.clientId())) {
|
||||
throw new OidcException(FAILED, "the token is for another client");
|
||||
}
|
||||
if (audiences(claims).size() > 1 && !settings.clientId().equals(claims.path("azp").asText())) {
|
||||
throw new OidcException(FAILED, "wrong authorized party");
|
||||
}
|
||||
if (!claims.path("exp").canConvertToLong() || claims.get("exp").asLong() + SKEW.toSeconds() <= now) {
|
||||
throw new OidcException(FAILED, "the token has expired");
|
||||
}
|
||||
if (!claims.path("iat").canConvertToLong() || claims.get("iat").asLong() - SKEW.toSeconds() > now) {
|
||||
throw new OidcException(FAILED, "the token is issued in the future");
|
||||
}
|
||||
boolean sameNonce = MessageDigest.isEqual(nonce.getBytes(StandardCharsets.UTF_8),
|
||||
claims.path("nonce").asText("").getBytes(StandardCharsets.UTF_8));
|
||||
if (!sameNonce) {
|
||||
throw new OidcException(FAILED, "wrong nonce");
|
||||
}
|
||||
}
|
||||
|
||||
/** A realm role or a role of this client, as Keycloak puts them in {@code realm_access} and {@code resource_access}. */
|
||||
private void checkRole(JsonNode claims) throws OidcException {
|
||||
String role = settings.requiredRole();
|
||||
if (role == null) {
|
||||
return;
|
||||
}
|
||||
boolean has = contains(claims.path("realm_access").path("roles"), role)
|
||||
|| contains(claims.path("resource_access").path(settings.clientId()).path("roles"), role);
|
||||
if (!has) {
|
||||
throw new OidcException(DENIED, "the user lacks the role " + role);
|
||||
}
|
||||
}
|
||||
|
||||
private String userName(JsonNode claims) throws OidcException {
|
||||
for (String claim : new String[] {"preferred_username", "email", "sub"}) {
|
||||
String name = claims.path(claim).asText("");
|
||||
if (Session.isValidUser(name)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
throw new OidcException(FAILED, "the token has no usable user name");
|
||||
}
|
||||
|
||||
private static List<String> audiences(JsonNode claims) {
|
||||
JsonNode aud = claims.path("aud");
|
||||
var all = new ArrayList<String>();
|
||||
if (aud.isArray()) {
|
||||
aud.forEach(a -> all.add(a.asText()));
|
||||
} else if (aud.isTextual()) {
|
||||
all.add(aud.asText());
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
private static boolean contains(JsonNode array, String value) {
|
||||
for (JsonNode item : array) {
|
||||
if (value.equals(item.asText())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void verifySignature(String signedPart, String signature, PublicKey key) throws OidcException {
|
||||
try {
|
||||
Signature rsa = Signature.getInstance("SHA256withRSA");
|
||||
rsa.initVerify(key);
|
||||
rsa.update(signedPart.getBytes(StandardCharsets.US_ASCII));
|
||||
if (!rsa.verify(Base64Url.decode(signature))) {
|
||||
throw new OidcException(FAILED, "the ID token signature is wrong");
|
||||
}
|
||||
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||
throw new OidcException(FAILED, "the ID token signature cannot be checked", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode json(String part) throws OidcException {
|
||||
try {
|
||||
return JSON.readTree(Base64Url.decode(part));
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
throw new OidcException(FAILED, "the ID token is malformed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Blocks a key (a username or a client address) after too many failures within a window. */
|
||||
public final class LoginThrottle {
|
||||
|
||||
private final Clock clock;
|
||||
private final int maxFailures;
|
||||
private final Duration window;
|
||||
/** The newest failures of each key, at most {@code maxFailures}, oldest first. */
|
||||
private final Map<String, ArrayDeque<Instant>> failures = new HashMap<>();
|
||||
|
||||
public LoginThrottle(Clock clock, int maxFailures, Duration window) {
|
||||
this.clock = clock;
|
||||
this.maxFailures = maxFailures;
|
||||
this.window = window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts an attempt against every key before the password is checked, so parallel guesses cannot
|
||||
* outrun the limit; {@link #release} takes it back.
|
||||
*
|
||||
* @return 0 when the attempt may go on, else the seconds until the most blocked key may try again
|
||||
*/
|
||||
public synchronized long tryAcquire(String... keys) {
|
||||
Instant now = clock.instant();
|
||||
long wait = 0;
|
||||
for (String key : keys) {
|
||||
var times = failures.get(key);
|
||||
if (times != null && times.size() >= maxFailures && times.peekFirst().plus(window).isAfter(now)) {
|
||||
wait = Math.max(wait, Duration.between(now, times.peekFirst().plus(window)).toSeconds() + 1);
|
||||
}
|
||||
}
|
||||
if (wait > 0) {
|
||||
return wait;
|
||||
}
|
||||
failures.values().removeIf(times -> !times.peekLast().plus(window).isAfter(now));
|
||||
for (String key : keys) {
|
||||
var times = failures.computeIfAbsent(key, k -> new ArrayDeque<>());
|
||||
times.addLast(now);
|
||||
if (times.size() > maxFailures) {
|
||||
times.removeFirst();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Takes back the newest attempt of each key. */
|
||||
public synchronized void release(String... keys) {
|
||||
for (String key : keys) {
|
||||
var times = failures.get(key);
|
||||
if (times != null) {
|
||||
times.pollLast();
|
||||
if (times.isEmpty()) {
|
||||
failures.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void cleared(String key) {
|
||||
failures.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
/** Why an OIDC sign-in did not happen; the code is what the login screen is told. */
|
||||
public final class OidcException extends Exception {
|
||||
|
||||
public enum Reason {
|
||||
/** Keycloak did not answer. */
|
||||
UNAVAILABLE("oidc_unavailable"),
|
||||
/** The user said no, or lacks the required role. */
|
||||
DENIED("oidc_denied"),
|
||||
/** Anything else: bad state, a token that does not verify, a refused code. */
|
||||
FAILED("oidc_failed");
|
||||
|
||||
private final String code;
|
||||
|
||||
Reason(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
private final Reason reason;
|
||||
|
||||
public OidcException(Reason reason, String message) {
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public OidcException(Reason reason, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public Reason reason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
|
||||
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||
|
||||
/**
|
||||
* Authorization code flow with PKCE (S256), state and nonce. What must survive the trip to the identity
|
||||
* provider travels in a short-lived signed cookie, so the backend keeps no state.
|
||||
*/
|
||||
public final class OidcFlow {
|
||||
|
||||
/** Where to send the browser, and the cookie to set on that response. */
|
||||
public record Start(URI location, String setCookie) {
|
||||
}
|
||||
|
||||
private static final String COOKIE = "hp_oidc";
|
||||
private static final String PURPOSE = "oidc-login";
|
||||
private static final String PATH = "/api/auth/oidc";
|
||||
private static final Duration LOGIN_TIME = Duration.ofMinutes(10);
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final OidcSettings settings;
|
||||
private final OidcProvider provider;
|
||||
private final IdTokenVerifier verifier;
|
||||
private final SignedTokens tokens;
|
||||
|
||||
OidcFlow(OidcSettings settings, SignedTokens tokens, Clock clock) {
|
||||
this.settings = settings;
|
||||
this.provider = new OidcProvider(settings, TIMEOUT, clock);
|
||||
this.verifier = new IdTokenVerifier(settings, clock);
|
||||
this.tokens = tokens;
|
||||
}
|
||||
|
||||
/** @throws OidcException UNAVAILABLE when the identity provider does not answer */
|
||||
public Start start() throws OidcException {
|
||||
String state = random();
|
||||
String nonce = random();
|
||||
String verifierText = random();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("response_type", "code");
|
||||
query.put("client_id", settings.clientId());
|
||||
query.put("redirect_uri", settings.redirectUri().toString());
|
||||
query.put("scope", "openid profile");
|
||||
query.put("state", state);
|
||||
query.put("nonce", nonce);
|
||||
query.put("code_challenge", challenge(verifierText));
|
||||
query.put("code_challenge_method", "S256");
|
||||
URI endpoint = provider.endpoints().authorization();
|
||||
String separator = endpoint.getRawQuery() == null ? "?" : "&";
|
||||
String cookie = Cookies.set(COOKIE, tokens.issue(PURPOSE, LOGIN_TIME,
|
||||
Map.of("state", state, "nonce", nonce, "verifier", verifierText)), LOGIN_TIME, PATH);
|
||||
return new Start(URI.create(endpoint + separator + query.entrySet().stream()
|
||||
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
|
||||
.collect(Collectors.joining("&"))), cookie);
|
||||
}
|
||||
|
||||
/** @return the signed-in user's name */
|
||||
public String callback(Map<String, String> query, String cookieHeader) throws OidcException {
|
||||
Map<String, String> login = Cookies.read(cookieHeader, COOKIE).flatMap(t -> tokens.read(PURPOSE, t))
|
||||
.orElseThrow(() -> new OidcException(FAILED, "no login in progress, or it took too long"));
|
||||
boolean sameState = MessageDigest.isEqual(login.get("state").getBytes(StandardCharsets.UTF_8),
|
||||
query.getOrDefault("state", "").getBytes(StandardCharsets.UTF_8));
|
||||
if (!sameState) {
|
||||
throw new OidcException(FAILED, "wrong state");
|
||||
}
|
||||
if ("access_denied".equals(query.get("error"))) {
|
||||
throw new OidcException(DENIED, "the user denied access");
|
||||
}
|
||||
String code = query.get("code");
|
||||
if (code == null || code.isEmpty()) {
|
||||
throw new OidcException(FAILED, "the identity provider answered with " + printable(query.get("error")));
|
||||
}
|
||||
return verifier.verify(provider.exchange(code, login.get("verifier")), login.get("nonce"), provider);
|
||||
}
|
||||
|
||||
public String clearCookie() {
|
||||
return Cookies.clear(COOKIE, PATH);
|
||||
}
|
||||
|
||||
/** The provider's text goes into the log: letters, digits and a few separators only, and short. */
|
||||
static String printable(String text) {
|
||||
String safe = text == null ? "nothing" : text.replaceAll("[^A-Za-z0-9_. -]", "?");
|
||||
return safe.length() > 40 ? safe.substring(0, 40) + "..." : safe;
|
||||
}
|
||||
|
||||
private static String random() {
|
||||
byte[] bytes = new byte[32];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64Url.encode(bytes);
|
||||
}
|
||||
|
||||
private static String challenge(String verifierText) {
|
||||
try {
|
||||
return Base64Url.encode(MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifierText.getBytes(StandardCharsets.US_ASCII)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 is part of every JDK", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.RSAPublicKeySpec;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||
import static home.backend.adapters.auth.OidcException.Reason.UNAVAILABLE;
|
||||
|
||||
/**
|
||||
* The identity provider over HTTP: discovery, signing keys and the code exchange. Everything is fetched on first
|
||||
* use and kept, so an identity provider that is down never delays the start and never affects local login.
|
||||
*/
|
||||
final class OidcProvider implements IdTokenVerifier.Keys {
|
||||
|
||||
record Endpoints(URI authorization, URI token, URI jwks) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Duration CACHE = Duration.ofHours(1);
|
||||
/** An unknown key id may mean a rotation; look again, but not on every forged token. */
|
||||
private static final Duration REFETCH_AFTER = Duration.ofMinutes(1);
|
||||
|
||||
private final OidcSettings settings;
|
||||
private final HttpClient http;
|
||||
private final Duration timeout;
|
||||
private final Clock clock;
|
||||
private Endpoints endpoints;
|
||||
private Instant endpointsAt = Instant.MIN;
|
||||
private Map<String, PublicKey> keys = Map.of();
|
||||
private Instant keysAt = Instant.MIN;
|
||||
|
||||
OidcProvider(OidcSettings settings, Duration timeout, Clock clock) {
|
||||
this.settings = settings;
|
||||
this.timeout = timeout;
|
||||
this.clock = clock;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
}
|
||||
|
||||
/** The network calls run outside the lock, so a hanging identity provider holds up only its own callers. */
|
||||
Endpoints endpoints() throws OidcException {
|
||||
synchronized (this) {
|
||||
if (endpoints != null && !endpointsAt.plus(CACHE).isBefore(clock.instant())) {
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
String base = settings.issuerBase();
|
||||
JsonNode doc = getJson(URI.create(base + "/.well-known/openid-configuration"));
|
||||
if (!base.equals(doc.path("issuer").asText("").replaceAll("/+$", ""))) {
|
||||
throw new OidcException(FAILED, "the discovery document names another issuer");
|
||||
}
|
||||
var fresh = new Endpoints(uri(doc, "authorization_endpoint"), uri(doc, "token_endpoint"), uri(doc, "jwks_uri"));
|
||||
synchronized (this) {
|
||||
endpoints = fresh;
|
||||
endpointsAt = clock.instant();
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicKey key(String kid) throws OidcException {
|
||||
synchronized (this) {
|
||||
boolean stale = keysAt.plus(CACHE).isBefore(clock.instant());
|
||||
boolean unknownAndRefetchable = !keys.containsKey(kid)
|
||||
&& keysAt.plus(REFETCH_AFTER).isBefore(clock.instant());
|
||||
if (!stale && !unknownAndRefetchable) {
|
||||
return known(kid);
|
||||
}
|
||||
}
|
||||
Map<String, PublicKey> fresh = parseKeys(getJson(endpoints().jwks()));
|
||||
synchronized (this) {
|
||||
keys = fresh;
|
||||
keysAt = clock.instant();
|
||||
return known(kid);
|
||||
}
|
||||
}
|
||||
|
||||
private PublicKey known(String kid) throws OidcException {
|
||||
PublicKey key = keys.get(kid);
|
||||
if (key == null) {
|
||||
throw new OidcException(FAILED, "no signing key " + kid);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/** @return the ID token the identity provider issued for the code */
|
||||
String exchange(String code, String verifier) throws OidcException {
|
||||
var form = Map.of("grant_type", "authorization_code", "code", code,
|
||||
"redirect_uri", settings.redirectUri().toString(), "code_verifier", verifier);
|
||||
String credentials = encode(settings.clientId()) + ":" + encode(settings.clientSecret());
|
||||
var request = HttpRequest.newBuilder(endpoints().token()).timeout(timeout)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Authorization", "Basic " + Base64.getEncoder()
|
||||
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8)))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(form.entrySet().stream()
|
||||
.map(e -> e.getKey() + "=" + encode(e.getValue())).collect(Collectors.joining("&"))))
|
||||
.build();
|
||||
JsonNode answer = send(request, "the token endpoint");
|
||||
String idToken = answer.path("id_token").asText("");
|
||||
if (idToken.isEmpty()) {
|
||||
throw new OidcException(FAILED, "the token endpoint sent no id_token");
|
||||
}
|
||||
return idToken;
|
||||
}
|
||||
|
||||
private JsonNode getJson(URI uri) throws OidcException {
|
||||
return send(HttpRequest.newBuilder(uri).timeout(timeout).GET().build(), uri.getPath());
|
||||
}
|
||||
|
||||
private JsonNode send(HttpRequest request, String what) throws OidcException {
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() >= 500) {
|
||||
throw new OidcException(UNAVAILABLE, what + " answered " + response.statusCode());
|
||||
}
|
||||
if (response.statusCode() != 200) {
|
||||
throw new OidcException(FAILED, what + " answered " + response.statusCode());
|
||||
}
|
||||
return JSON.readTree(response.body());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new OidcException(UNAVAILABLE, what + " was interrupted", e);
|
||||
} catch (IOException e) {
|
||||
throw new OidcException(UNAVAILABLE, what + " is unreachable: " + e, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** A key that cannot be read is skipped, so one odd entry does not lock everyone out. */
|
||||
private static Map<String, PublicKey> parseKeys(JsonNode jwks) {
|
||||
Map<String, PublicKey> keys = new HashMap<>();
|
||||
for (JsonNode jwk : jwks.path("keys")) {
|
||||
boolean signingRsa = "RSA".equals(jwk.path("kty").asText()) && !"enc".equals(jwk.path("use").asText());
|
||||
if (signingRsa && jwk.hasNonNull("kid")) {
|
||||
try {
|
||||
keys.put(jwk.get("kid").asText(), KeyFactory.getInstance("RSA").generatePublic(
|
||||
new RSAPublicKeySpec(integer(jwk, "n"), integer(jwk, "e"))));
|
||||
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||
System.err.println("home-pump-backend: skipping unusable signing key " + jwk.get("kid").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static BigInteger integer(JsonNode jwk, String field) {
|
||||
return new BigInteger(1, Base64Url.decode(jwk.path(field).asText()));
|
||||
}
|
||||
|
||||
private static URI uri(JsonNode doc, String field) throws OidcException {
|
||||
String value = doc.path(field).asText("");
|
||||
if (value.isEmpty()) {
|
||||
throw new OidcException(FAILED, "the discovery document has no " + field);
|
||||
}
|
||||
return URI.create(value);
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
/** requiredRole is null when any user of the realm may sign in. */
|
||||
public record OidcSettings(URI issuer, String clientId, String clientSecret, URI redirectUri, String requiredRole) {
|
||||
|
||||
/** The issuer without a trailing slash, the form tokens and discovery are compared in. */
|
||||
String issuerBase() {
|
||||
return issuer.toString().replaceAll("/+$", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/** Password hashes as {@code pbkdf2-sha256$<iterations>$<salt b64>$<hash b64>}: the cost travels with the hash. */
|
||||
public final class PasswordHash {
|
||||
|
||||
private static final String SCHEME = "pbkdf2-sha256";
|
||||
private static final int ITERATIONS = 600_000;
|
||||
/** A file with a cheaper hash is refused, so a weak one cannot slip in. */
|
||||
private static final int MIN_ITERATIONS = 310_000;
|
||||
private static final int SALT_BYTES = 16;
|
||||
private static final int HASH_BITS = 256;
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private record Parsed(int iterations, byte[] salt, byte[] hash) {
|
||||
}
|
||||
|
||||
private PasswordHash() {
|
||||
}
|
||||
|
||||
public static String create(char[] password) {
|
||||
byte[] salt = new byte[SALT_BYTES];
|
||||
RANDOM.nextBytes(salt);
|
||||
return String.join("$", SCHEME, String.valueOf(ITERATIONS), Base64.getEncoder().encodeToString(salt),
|
||||
Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS, HASH_BITS)));
|
||||
}
|
||||
|
||||
/** Compares in constant time. */
|
||||
public static boolean matches(String stored, char[] password) {
|
||||
Parsed parsed = parse(stored);
|
||||
byte[] actual = derive(password, parsed.salt(), parsed.iterations(), parsed.hash().length * 8);
|
||||
return MessageDigest.isEqual(parsed.hash(), actual);
|
||||
}
|
||||
|
||||
/** @throws IllegalArgumentException when the text is not a hash this class made or accepts */
|
||||
public static void requireValid(String stored) {
|
||||
parse(stored);
|
||||
}
|
||||
|
||||
private static Parsed parse(String stored) {
|
||||
String[] parts = stored == null ? new String[0] : stored.split("\\$");
|
||||
if (parts.length != 4 || !SCHEME.equals(parts[0])) {
|
||||
throw new IllegalArgumentException("a password hash must look like " + SCHEME
|
||||
+ "$<iterations>$<salt>$<hash>; create one with: hash-password");
|
||||
}
|
||||
try {
|
||||
int iterations = Integer.parseInt(parts[1]);
|
||||
byte[] salt = Base64.getDecoder().decode(parts[2]);
|
||||
byte[] hash = Base64.getDecoder().decode(parts[3]);
|
||||
if (iterations < MIN_ITERATIONS || salt.length < SALT_BYTES || hash.length < HASH_BITS / 8) {
|
||||
throw new IllegalArgumentException("password hash is too weak: at least " + MIN_ITERATIONS
|
||||
+ " iterations, 16 bytes of salt, a 32 byte hash");
|
||||
}
|
||||
return new Parsed(iterations, salt, hash);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// NumberFormatException and a bad base64 are both IllegalArgumentException
|
||||
throw new IllegalArgumentException("password hash is malformed or too weak (at least " + MIN_ITERATIONS
|
||||
+ " iterations, 16 bytes of salt, a 32 byte hash)", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] derive(char[] password, byte[] salt, int iterations, int bits) {
|
||||
var spec = new PBEKeySpec(password, salt, iterations, bits);
|
||||
try {
|
||||
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("PBKDF2WithHmacSHA256 is part of every JDK", e);
|
||||
} finally {
|
||||
spec.clearPassword();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.Console;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
/** {@code hash-password}: prints the hash for USERS_FILE; on a terminal the password is asked twice, unechoed. */
|
||||
public final class PasswordHashCli {
|
||||
|
||||
private PasswordHashCli() {
|
||||
}
|
||||
|
||||
/** @return the exit code */
|
||||
public static int run(Console console, InputStream stdin, PrintStream out, PrintStream err) throws IOException {
|
||||
char[] password;
|
||||
if (console != null) {
|
||||
password = console.readPassword("Password: ");
|
||||
char[] again = console.readPassword("Again: ");
|
||||
if (password == null || !Arrays.equals(password, again)) {
|
||||
err.println("The passwords differ.");
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
String line = new BufferedReader(new InputStreamReader(stdin, StandardCharsets.UTF_8)).readLine();
|
||||
password = line == null ? null : line.toCharArray();
|
||||
}
|
||||
if (password == null || password.length == 0) {
|
||||
err.println("No password given.");
|
||||
return 1;
|
||||
}
|
||||
out.println(PasswordHash.create(password));
|
||||
Arrays.fill(password, '\0');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Who is signed in and how. {@code method} is {@code local}, {@code oidc} or {@code none} (AUTH_DISABLED). */
|
||||
public record Session(String user, String method, Instant issuedAt) {
|
||||
|
||||
/** The name goes to the gateway's audit log as a header, so only plain characters are allowed. */
|
||||
public static final String USER_PATTERN = "[\\w.@+-]{1,64}";
|
||||
private static final Pattern USER = Pattern.compile(USER_PATTERN);
|
||||
|
||||
/** Every request is this one when authentication is switched off for development. */
|
||||
static final Session OPEN = new Session("phone", "none", Instant.EPOCH);
|
||||
|
||||
public static boolean isValidUser(String name) {
|
||||
return USER.matcher(name).matches();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** The session cookie: a signed token that holds the user, the method and when it was issued. */
|
||||
final class Sessions {
|
||||
|
||||
static final String COOKIE = "hp_session";
|
||||
private static final String PURPOSE = "session";
|
||||
|
||||
private final SignedTokens tokens;
|
||||
private final Clock clock;
|
||||
private final Duration lifetime;
|
||||
|
||||
Sessions(SignedTokens tokens, Clock clock, Duration lifetime) {
|
||||
this.tokens = tokens;
|
||||
this.clock = clock;
|
||||
this.lifetime = lifetime;
|
||||
}
|
||||
|
||||
String setCookie(String user, String method) {
|
||||
String token = tokens.issue(PURPOSE, lifetime, Map.of("user", user, "method", method,
|
||||
"iat", String.valueOf(clock.instant().getEpochSecond())));
|
||||
return Cookies.set(COOKIE, token, lifetime, "/");
|
||||
}
|
||||
|
||||
String clearCookie() {
|
||||
return Cookies.clear(COOKIE, "/");
|
||||
}
|
||||
|
||||
Optional<Session> read(String cookieHeader) {
|
||||
return Cookies.read(cookieHeader, COOKIE).flatMap(token -> tokens.read(PURPOSE, token)).map(claims ->
|
||||
new Session(claims.get("user"), claims.get("method"),
|
||||
Instant.ofEpochSecond(Long.parseLong(claims.get("iat")))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Tamper-proof, expiring text for cookies: {@code base64url(json).base64url(HMAC-SHA256)}. */
|
||||
final class SignedTokens {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final TypeReference<Map<String, String>> CLAIMS = new TypeReference<>() {
|
||||
};
|
||||
private static final String HMAC = "HmacSHA256";
|
||||
|
||||
private final SecretKeySpec key;
|
||||
private final Clock clock;
|
||||
|
||||
SignedTokens(byte[] secret, Clock clock) {
|
||||
this.key = new SecretKeySpec(secret, HMAC);
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** The purpose keeps a token made for one use (a session) from passing as another (a login in progress). */
|
||||
String issue(String purpose, Duration lifetime, Map<String, String> claims) {
|
||||
var all = new HashMap<>(claims);
|
||||
all.put("purpose", purpose);
|
||||
all.put("exp", String.valueOf(clock.instant().plus(lifetime).getEpochSecond()));
|
||||
try {
|
||||
String payload = Base64Url.encode(JSON.writeValueAsBytes(all));
|
||||
return payload + "." + Base64Url.encode(sign(payload));
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
Optional<Map<String, String>> read(String purpose, String token) {
|
||||
try {
|
||||
String[] parts = token.split("\\.", -1);
|
||||
if (parts.length != 2 || !MessageDigest.isEqual(sign(parts[0]), Base64Url.decode(parts[1]))) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Map<String, String> claims = JSON.readValue(Base64Url.decode(parts[0]), CLAIMS);
|
||||
boolean valid = purpose.equals(claims.get("purpose"))
|
||||
&& Long.parseLong(claims.get("exp")) > clock.instant().getEpochSecond();
|
||||
return valid ? Optional.of(claims) : Optional.empty();
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] sign(String payload) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC);
|
||||
mac.init(key);
|
||||
return mac.doFinal(payload.getBytes(StandardCharsets.US_ASCII));
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException(HMAC + " is part of every JDK", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The local accounts of USERS_FILE: {@code [{"username", "passwordHash"}]}. A missing file is an empty store
|
||||
* (setup mode). Every change is written back atomically before it counts, under one lock.
|
||||
*/
|
||||
public final class UserStore {
|
||||
|
||||
public enum Removal { REMOVED, UNKNOWN, LAST }
|
||||
|
||||
private record Entry(String username, String passwordHash) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
/** What an unknown user is checked against, so that it costs as much as a wrong password. */
|
||||
private static final String DECOY = PasswordHash.create(randomPassword());
|
||||
|
||||
private final Path file;
|
||||
private Map<String, String> hashes;
|
||||
|
||||
UserStore(Path file, Map<String, String> hashes) {
|
||||
this.file = file;
|
||||
this.hashes = new LinkedHashMap<>(hashes);
|
||||
}
|
||||
|
||||
/** @throws IllegalArgumentException when the file exists but is unreadable or holds a bad account */
|
||||
public static UserStore load(Path file) {
|
||||
Map<String, String> hashes = new LinkedHashMap<>();
|
||||
if (Files.exists(file)) {
|
||||
List<Entry> entries;
|
||||
try {
|
||||
entries = JSON.readValue(file.toFile(), new TypeReference<>() {
|
||||
});
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("USERS_FILE " + file + " is not usable: " + e.getMessage(), e);
|
||||
}
|
||||
for (Entry entry : entries) {
|
||||
if (entry.username() == null || !Session.isValidUser(entry.username())) {
|
||||
throw new IllegalArgumentException("USERS_FILE: a username must match " + Session.USER_PATTERN);
|
||||
}
|
||||
PasswordHash.requireValid(entry.passwordHash());
|
||||
if (hashes.put(entry.username(), entry.passwordHash()) != null) {
|
||||
throw new IllegalArgumentException("USERS_FILE: duplicate username " + entry.username());
|
||||
}
|
||||
}
|
||||
}
|
||||
return new UserStore(file, hashes);
|
||||
}
|
||||
|
||||
public synchronized boolean isEmpty() {
|
||||
return hashes.isEmpty();
|
||||
}
|
||||
|
||||
public synchronized boolean exists(String username) {
|
||||
return hashes.containsKey(username);
|
||||
}
|
||||
|
||||
public synchronized List<String> usernames() {
|
||||
return List.copyOf(hashes.keySet());
|
||||
}
|
||||
|
||||
public boolean authenticate(String username, char[] password) {
|
||||
String stored;
|
||||
synchronized (this) {
|
||||
stored = hashes.get(username);
|
||||
}
|
||||
boolean matches = PasswordHash.matches(stored == null ? DECOY : stored, password);
|
||||
return stored != null && matches;
|
||||
}
|
||||
|
||||
/** The first account; false when there already is one, so only one setup can win. */
|
||||
public synchronized boolean addFirst(String username, String hash) {
|
||||
return hashes.isEmpty() && put(username, hash);
|
||||
}
|
||||
|
||||
/** @return false when the name is taken */
|
||||
public synchronized boolean add(String username, String hash) {
|
||||
return !hashes.containsKey(username) && put(username, hash);
|
||||
}
|
||||
|
||||
/** @return false when there is no such account */
|
||||
public synchronized boolean changePassword(String username, String hash) {
|
||||
return hashes.containsKey(username) && put(username, hash);
|
||||
}
|
||||
|
||||
public synchronized Removal remove(String username) {
|
||||
if (!hashes.containsKey(username)) {
|
||||
return Removal.UNKNOWN;
|
||||
}
|
||||
if (hashes.size() == 1) {
|
||||
return Removal.LAST;
|
||||
}
|
||||
var next = new LinkedHashMap<>(hashes);
|
||||
next.remove(username);
|
||||
write(next);
|
||||
hashes = next;
|
||||
return Removal.REMOVED;
|
||||
}
|
||||
|
||||
private boolean put(String username, String hash) {
|
||||
var next = new LinkedHashMap<>(hashes);
|
||||
next.put(username, hash);
|
||||
write(next);
|
||||
hashes = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Temp file next to the target, synced, then renamed over it; the old file stays if anything fails. */
|
||||
private void write(Map<String, String> accounts) {
|
||||
Path directory = file.toAbsolutePath().getParent();
|
||||
Path temp = null;
|
||||
try {
|
||||
temp = Files.createTempFile(directory, "users", ".tmp",
|
||||
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
||||
Files.write(temp, JSON.writerWithDefaultPrettyPrinter().writeValueAsBytes(accounts.entrySet().stream()
|
||||
.map(e -> new Entry(e.getKey(), e.getValue())).toList()));
|
||||
try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException e) {
|
||||
if (temp != null) {
|
||||
temp.toFile().delete();
|
||||
}
|
||||
throw new UncheckedIOException("cannot write USERS_FILE " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static char[] randomPassword() {
|
||||
byte[] bytes = new byte[24];
|
||||
new SecureRandom().nextBytes(bytes);
|
||||
return Base64.getEncoder().encodeToString(bytes).toCharArray();
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayBattery;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.application.GatewayRefresh;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView.Reserve;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RefreshResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
@@ -25,8 +27,8 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** Sends commands to the gateway's {@code /commands} and {@code /battery} with the shared token (S6). */
|
||||
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
|
||||
/** Calls the gateway's {@code /commands}, {@code /battery} and {@code /refresh} with the shared token (S6). */
|
||||
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery, GatewayRefresh {
|
||||
|
||||
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
|
||||
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 renewEndpoint;
|
||||
private final URI restoreEndpoint;
|
||||
private final URI refreshEndpoint;
|
||||
private final URI reserveEndpoint;
|
||||
private final URI scheduleEndpoint;
|
||||
private final String token;
|
||||
@@ -77,6 +80,7 @@ public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
|
||||
this.endpoint = baseUri.resolve("/commands");
|
||||
this.renewEndpoint = baseUri.resolve("/commands/renew");
|
||||
this.restoreEndpoint = baseUri.resolve("/commands/restore");
|
||||
this.refreshEndpoint = baseUri.resolve("/refresh");
|
||||
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
|
||||
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
|
||||
this.token = token;
|
||||
@@ -113,6 +117,12 @@ public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
|
||||
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
|
||||
public Reserve reserve() throws IOException {
|
||||
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
|
||||
|
||||
@@ -9,14 +9,20 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.GatewayRefresh;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.ProgramService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.application.WeatherService;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
@@ -51,8 +57,11 @@ public class ApiServer {
|
||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
||||
}
|
||||
|
||||
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
|
||||
private record PresetRequest(String until, Map<String, Integer> values) {
|
||||
/**
|
||||
* {@code hours}: how long a timed preset lasts; {@code values}: the user's own value per setpoint of the
|
||||
* preset, by setpoint name.
|
||||
*/
|
||||
private record PresetRequest(String until, Integer hours, Map<String, Integer> values) {
|
||||
|
||||
PresetRequest {
|
||||
if (values == null) {
|
||||
@@ -69,11 +78,6 @@ public class ApiServer {
|
||||
private static final String PRESETS_PATH = "/api/presets/";
|
||||
private static final String SETPOINTS_PATH = "/api/setpoints/";
|
||||
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
|
||||
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
|
||||
private static final String USER_HEADER = "Remote-User";
|
||||
private static final String DEFAULT_USER = "phone";
|
||||
private static final Pattern USER_NAME = Pattern.compile("[\\w.@+-]{1,64}");
|
||||
|
||||
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
|
||||
|
||||
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
|
||||
@@ -81,21 +85,40 @@ public class ApiServer {
|
||||
private static final int STOP_GRACE_SECONDS = 5;
|
||||
|
||||
private final HttpServer server;
|
||||
private final SessionFilter sessionFilter;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/** Binds the port now; 0 picks a free one. */
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
|
||||
BatteryService battery) throws IOException {
|
||||
BatteryService battery, AuditService audit, StatsService stats, ProgramService program, WeatherService weather,
|
||||
GatewayRefresh refresh, Authentication auth) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(executor);
|
||||
sessionFilter = new SessionFilter(auth);
|
||||
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
|
||||
context("/", exchange -> {
|
||||
open("/", exchange -> {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
});
|
||||
var signIn = new AuthEndpoints(auth);
|
||||
openRoute("/api/auth/config", signIn::config);
|
||||
openRoute("/api/auth/login", signIn::login);
|
||||
openRoute("/api/auth/logout", signIn::logout);
|
||||
openRoute("/api/auth/me", signIn::me);
|
||||
openRoute("/api/auth/setup", signIn::setup);
|
||||
openRoute("/api/auth/users", signIn::users);
|
||||
openRoute("/api/auth/password", signIn::password);
|
||||
open("/api/auth/users/", signIn::deleteUser);
|
||||
openRoute("/api/auth/oidc/start", signIn::oidcStart);
|
||||
openRoute("/api/auth/oidc/callback", signIn::oidcCallback);
|
||||
// The container healthcheck: no session, nothing in the answer.
|
||||
openRoute("/api/live", exchange -> handle(exchange, Map.of("GET", () -> {
|
||||
exchange.sendResponseHeaders(200, 2);
|
||||
exchange.getResponseBody().write("ok".getBytes(StandardCharsets.US_ASCII));
|
||||
})));
|
||||
api("/api/heat-pump", query -> heatPump.current());
|
||||
api("/api/energy", query -> energy.current());
|
||||
api("/api/energy/details", query -> energyDetails.current());
|
||||
@@ -104,9 +127,15 @@ public class ApiServer {
|
||||
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
|
||||
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
|
||||
route("/api/refresh", exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postRefresh(exchange, refresh)))));
|
||||
api("/api/pi-sensor", query -> piSensor.current());
|
||||
api("/api/weather", query -> weather.current());
|
||||
api("/api/health", query -> health.current());
|
||||
route("/api/history", exchange -> handleHistory(exchange, history));
|
||||
route("/api/audit", exchange -> handleAudit(exchange, audit));
|
||||
route("/api/stats", exchange -> handleStats(exchange, stats));
|
||||
route("/api/program", exchange -> handleProgram(exchange, program));
|
||||
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
||||
"GET", () -> getSetpoints(exchange, setpoints),
|
||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||
@@ -125,9 +154,17 @@ public class ApiServer {
|
||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
|
||||
}
|
||||
|
||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||
private void route(String path, HttpHandler handler) {
|
||||
context(path, exchange -> {
|
||||
context(path, exactly(path, handler));
|
||||
}
|
||||
|
||||
private void openRoute(String path, HttpHandler handler) {
|
||||
open(path, exactly(path, handler));
|
||||
}
|
||||
|
||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||
private static HttpHandler exactly(String path, HttpHandler handler) {
|
||||
return exchange -> {
|
||||
if (path.equals(exchange.getRequestURI().getPath())) {
|
||||
handler.handle(exchange);
|
||||
} else {
|
||||
@@ -135,10 +172,18 @@ public class ApiServer {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Needs a session. */
|
||||
private void context(String path, HttpHandler handler) {
|
||||
var context = server.createContext(path, handler);
|
||||
context.getFilters().add(SECURITY_HEADERS);
|
||||
context.getFilters().add(sessionFilter);
|
||||
}
|
||||
|
||||
/** Answers without a session. */
|
||||
private void open(String path, HttpHandler handler) {
|
||||
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
|
||||
}
|
||||
|
||||
@@ -174,7 +219,7 @@ public class ApiServer {
|
||||
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
|
||||
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
|
||||
*/
|
||||
private static void guarded(HttpExchange exchange, Write write) throws IOException {
|
||||
static void guarded(HttpExchange exchange, Write write) throws IOException {
|
||||
var headers = exchange.getRequestHeaders();
|
||||
String origin = headers.getFirst("Origin");
|
||||
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
|
||||
@@ -252,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)
|
||||
throws IOException {
|
||||
try {
|
||||
@@ -266,14 +319,17 @@ public class ApiServer {
|
||||
throws IOException {
|
||||
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
||||
try {
|
||||
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
|
||||
var request = body.length == 0 ? new PresetRequest(null, null, null) : JSON.readValue(body, PresetRequest.class);
|
||||
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
|
||||
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
|
||||
var result = setpoints.applyPreset(id, until, request.hours(), request.values(),
|
||||
user(exchange));
|
||||
if (result.isPresent()) {
|
||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||
} else {
|
||||
send(exchange, 404, error("no such preset"));
|
||||
}
|
||||
} catch (SetpointService.InvalidHoursException e) {
|
||||
send(exchange, 400, error("invalid_hours"));
|
||||
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
|
||||
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
|
||||
@@ -319,9 +375,9 @@ public class ApiServer {
|
||||
};
|
||||
}
|
||||
|
||||
/** The signed-in user, set by the session filter. */
|
||||
private static String user(HttpExchange exchange) {
|
||||
String name = exchange.getRequestHeaders().getFirst(USER_HEADER);
|
||||
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
|
||||
return (String) exchange.getAttribute(SessionFilter.USER_ATTRIBUTE);
|
||||
}
|
||||
|
||||
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
||||
@@ -337,15 +393,52 @@ public class ApiServer {
|
||||
}));
|
||||
}
|
||||
|
||||
private interface Action {
|
||||
private void handleAudit(HttpExchange exchange, AuditService audit) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
|
||||
try {
|
||||
send(exchange, 200, audit.audit(query.get("range")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("audit unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleStats(HttpExchange exchange, StatsService stats) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
try {
|
||||
send(exchange, 200, stats.stats(parseQuery(exchange.getRequestURI().getRawQuery()).get("range")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("stats unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleProgram(HttpExchange exchange, ProgramService program) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
try {
|
||||
send(exchange, 200, program.program(parseQuery(exchange.getRequestURI().getRawQuery()).get("days")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("program unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
interface Action {
|
||||
void run() throws IOException;
|
||||
}
|
||||
|
||||
private interface Write {
|
||||
interface Write {
|
||||
void run(byte[] body) throws IOException;
|
||||
}
|
||||
|
||||
private static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
|
||||
static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
|
||||
try (exchange) {
|
||||
Action action = byMethod.get(exchange.getRequestMethod());
|
||||
if (action == null) {
|
||||
@@ -364,7 +457,7 @@ public class ApiServer {
|
||||
}
|
||||
}
|
||||
|
||||
private static void send(HttpExchange exchange, int status, Object body) throws IOException {
|
||||
static void send(HttpExchange exchange, int status, Object body) throws IOException {
|
||||
byte[] bytes = JSON.writeValueAsBytes(body);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
@@ -376,7 +469,7 @@ public class ApiServer {
|
||||
return Map.of("error", message);
|
||||
}
|
||||
|
||||
private static Map<String, String> parseQuery(String raw) {
|
||||
static Map<String, String> parseQuery(String raw) {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
if (raw == null) {
|
||||
return query;
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.adapters.auth.OidcException;
|
||||
import home.backend.adapters.auth.Session;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** The endpoints under {@code /api/auth/}: sign-in needs no session, managing accounts does. */
|
||||
final class AuthEndpoints {
|
||||
|
||||
private record LoginRequest(String username, String password) {
|
||||
}
|
||||
|
||||
private record PasswordRequest(String current, @JsonProperty("new") String next) {
|
||||
}
|
||||
|
||||
private static final Pattern DELETE_PATH = Pattern.compile("/api/auth/users/([^/]{1,64})/delete");
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final int MAX_CREDENTIAL_LENGTH = 256;
|
||||
|
||||
private final Authentication auth;
|
||||
|
||||
AuthEndpoints(Authentication auth) {
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
void config(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("GET", () -> ApiServer.send(exchange, 200,
|
||||
Map.of("local", auth.localEnabled(), "oidc", auth.oidcEnabled(),
|
||||
"setup", auth.setupNeeded()))));
|
||||
}
|
||||
|
||||
void login(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> postLogin(exchange, body))));
|
||||
}
|
||||
|
||||
void logout(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
|
||||
String cookie = auth.clearedSessionCookie();
|
||||
if (!cookie.isEmpty()) {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", cookie);
|
||||
}
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
})));
|
||||
}
|
||||
|
||||
void me(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("GET", () -> {
|
||||
var session = auth.session(SessionFilter.cookies(exchange));
|
||||
if (session.isPresent()) {
|
||||
ApiServer.send(exchange, 200, user(session.get()));
|
||||
} else {
|
||||
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void oidcStart(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("GET", () -> {
|
||||
if (!auth.oidcEnabled()) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var start = auth.oidc().start();
|
||||
exchange.getResponseHeaders().add("Set-Cookie", start.setCookie());
|
||||
redirect(exchange, start.location().toString());
|
||||
} catch (OidcException e) {
|
||||
redirectWithError(exchange, e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void oidcCallback(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("GET", () -> {
|
||||
if (!auth.oidcEnabled()) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.oidc().clearCookie());
|
||||
try {
|
||||
String name = auth.oidc().callback(ApiServer.parseQuery(exchange.getRequestURI().getRawQuery()),
|
||||
SessionFilter.cookies(exchange));
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(name, "oidc"));
|
||||
redirect(exchange, "/");
|
||||
} catch (OidcException e) {
|
||||
redirectWithError(exchange, e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void postLogin(HttpExchange exchange, byte[] body) throws IOException {
|
||||
if (!auth.localEnabled()) {
|
||||
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||
return;
|
||||
}
|
||||
LoginRequest request = credentials(exchange, body);
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
String ip = ClientIp.of(exchange, auth.proxyHops());
|
||||
var login = auth.login(request.username(), request.password().toCharArray(), ip);
|
||||
switch (login.status()) {
|
||||
case OK -> {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
|
||||
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
|
||||
}
|
||||
case INVALID -> {
|
||||
System.err.println("home-pump-backend: failed login from " + ip);
|
||||
ApiServer.send(exchange, 401, Map.of("error", "invalid"));
|
||||
}
|
||||
case THROTTLED -> throttled(exchange, login);
|
||||
}
|
||||
}
|
||||
|
||||
/** The username and password of the body, or null after answering 400. */
|
||||
private static LoginRequest credentials(HttpExchange exchange, byte[] body) throws IOException {
|
||||
LoginRequest request;
|
||||
try {
|
||||
request = JSON.readValue(body, LoginRequest.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
request = null;
|
||||
}
|
||||
if (request == null || request.username() == null || request.password() == null
|
||||
|| request.username().length() > MAX_CREDENTIAL_LENGTH
|
||||
|| request.password().length() > MAX_CREDENTIAL_LENGTH) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "expected {\"username\", \"password\"}"));
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private static void throttled(HttpExchange exchange, Authentication.Login login) throws IOException {
|
||||
exchange.getResponseHeaders().set("Retry-After", String.valueOf(login.retryAfterSeconds()));
|
||||
ApiServer.send(exchange, 429, Map.of("error", "throttled", "retryAfterSeconds", login.retryAfterSeconds()));
|
||||
}
|
||||
|
||||
/** True when the name and password may become an account; else answers 400. */
|
||||
private static boolean acceptable(HttpExchange exchange, String username, String password) throws IOException {
|
||||
if (!Session.isValidUser(username)) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "invalid_username"));
|
||||
return false;
|
||||
}
|
||||
if (!Authentication.strongEnough(password)) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void setup(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
|
||||
if (!auth.localEnabled()) {
|
||||
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||
return;
|
||||
}
|
||||
LoginRequest request = credentials(exchange, body);
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
if (!auth.setupNeeded()) {
|
||||
ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
|
||||
return;
|
||||
}
|
||||
if (!acceptable(exchange, request.username(), request.password())) {
|
||||
return;
|
||||
}
|
||||
var result = auth.setup(request.username(), request.password().toCharArray(),
|
||||
ClientIp.of(exchange, auth.proxyHops()));
|
||||
switch (result.status()) {
|
||||
case OK -> {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
|
||||
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
|
||||
}
|
||||
case INVALID -> ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
|
||||
case THROTTLED -> throttled(exchange, result);
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
/** Runs the action for a signed-in user of a configured local store; else answers 401 or 404. */
|
||||
private void asUser(HttpExchange exchange, SignedIn action) throws IOException {
|
||||
var session = auth.session(SessionFilter.cookies(exchange));
|
||||
if (session.isEmpty()) {
|
||||
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||
} else if (!auth.localEnabled()) {
|
||||
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||
} else {
|
||||
action.run(session.get());
|
||||
}
|
||||
}
|
||||
|
||||
private interface SignedIn {
|
||||
void run(Session session) throws IOException;
|
||||
}
|
||||
|
||||
void users(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of(
|
||||
"GET", () -> asUser(exchange, session -> ApiServer.send(exchange, 200,
|
||||
auth.usernames().stream().map(name -> Map.of("username", name)).toList())),
|
||||
"POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||
LoginRequest request = credentials(exchange, body);
|
||||
if (request == null || !acceptable(exchange, request.username(), request.password())) {
|
||||
return;
|
||||
}
|
||||
if (auth.addUser(session.user(), request.username(), request.password().toCharArray())) {
|
||||
ApiServer.send(exchange, 200, Map.of("username", request.username()));
|
||||
} else {
|
||||
ApiServer.send(exchange, 409, Map.of("error", "exists"));
|
||||
}
|
||||
}))));
|
||||
}
|
||||
|
||||
void password(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||
PasswordRequest request;
|
||||
try {
|
||||
request = JSON.readValue(body, PasswordRequest.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
request = null;
|
||||
}
|
||||
if (request == null || request.current() == null || request.next() == null
|
||||
|| request.current().length() > MAX_CREDENTIAL_LENGTH
|
||||
|| request.next().length() > MAX_CREDENTIAL_LENGTH) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "expected {\"current\", \"new\"}"));
|
||||
} else if (!Authentication.LOCAL.equals(session.method())) {
|
||||
ApiServer.send(exchange, 403, Map.of("error", "local_only"));
|
||||
} else if (!Authentication.strongEnough(request.next())) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
|
||||
} else {
|
||||
var result = auth.changePassword(session.user(), request.current().toCharArray(),
|
||||
request.next().toCharArray(), ClientIp.of(exchange, auth.proxyHops()));
|
||||
switch (result.status()) {
|
||||
case OK -> exchange.sendResponseHeaders(204, -1);
|
||||
case INVALID -> ApiServer.send(exchange, 401, Map.of("error", "invalid"));
|
||||
case THROTTLED -> throttled(exchange, result);
|
||||
}
|
||||
}
|
||||
}))));
|
||||
}
|
||||
|
||||
void deleteUser(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||
var path = DELETE_PATH.matcher(exchange.getRequestURI().getRawPath());
|
||||
if (!path.matches()) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
String name = path.group(1);
|
||||
switch (auth.removeUser(session.user(), name)) {
|
||||
case UNKNOWN -> ApiServer.send(exchange, 404, Map.of("error", "unknown_user"));
|
||||
case LAST -> ApiServer.send(exchange, 409, Map.of("error", "last_user"));
|
||||
case REMOVED -> {
|
||||
if (name.equals(session.user())) {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.clearedSessionCookie());
|
||||
}
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
}
|
||||
}
|
||||
}))));
|
||||
}
|
||||
|
||||
private static Map<String, String> user(Session session) {
|
||||
return Map.of("user", session.user(), "method", session.method());
|
||||
}
|
||||
|
||||
private static void redirectWithError(HttpExchange exchange, OidcException e) throws IOException {
|
||||
System.err.println("home-pump-backend: OIDC sign-in failed: " + e.getMessage());
|
||||
redirect(exchange, "/#login?error=" + e.reason().code());
|
||||
}
|
||||
|
||||
private static void redirect(HttpExchange exchange, String location) throws IOException {
|
||||
exchange.getResponseHeaders().set("Location", location);
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.sendResponseHeaders(302, -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The client's address for the login throttle. Each trusted proxy appends the address it saw to
|
||||
* X-Forwarded-For, so the entry {@code hops} from the right is the first one no trusted proxy vouches for
|
||||
* beyond that; anything the client put in front of it is ignored. Without enough entries the request did not
|
||||
* come through the proxies, and the socket's address is used.
|
||||
*/
|
||||
final class ClientIp {
|
||||
|
||||
private ClientIp() {
|
||||
}
|
||||
|
||||
static String of(HttpExchange exchange, int hops) {
|
||||
List<String> values = exchange.getRequestHeaders().get("X-Forwarded-For");
|
||||
if (values != null) {
|
||||
List<String> chain = Arrays.stream(String.join(",", values).split(",")).map(String::trim).toList();
|
||||
if (chain.size() >= hops && !chain.get(chain.size() - hops).isEmpty()) {
|
||||
return chain.get(chain.size() - hops);
|
||||
}
|
||||
}
|
||||
return exchange.getRemoteAddress().getAddress().getHostAddress();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.sun.net.httpserver.Filter;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.adapters.auth.Session;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Lets a request through only with a valid session, and hands the user's name on to the handlers. */
|
||||
final class SessionFilter extends Filter {
|
||||
|
||||
static final String USER_ATTRIBUTE = "user";
|
||||
|
||||
private final Authentication auth;
|
||||
|
||||
SessionFilter(Authentication auth) {
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
|
||||
Optional<Session> session = auth.session(cookies(exchange));
|
||||
if (session.isPresent()) {
|
||||
exchange.setAttribute(USER_ATTRIBUTE, session.get().user());
|
||||
chain.doFilter(exchange);
|
||||
} else {
|
||||
try (exchange) {
|
||||
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String cookies(HttpExchange exchange) {
|
||||
var values = exchange.getRequestHeaders().get("Cookie");
|
||||
return values == null ? null : String.join("; ", values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "session check";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.AuditRepository;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The gateway's audit measurements (S3) read from InfluxDB; never written. */
|
||||
public final class InfluxAuditRepository implements AuditRepository {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxAuditRepository(InfluxSettings settings) {
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) throws IOException {
|
||||
return rows("Dom_Pompa", "setpoint_audit", "setpoint", from, to);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) throws IOException {
|
||||
return rows("electricity", "deye_settings_audit", "register", from, to);
|
||||
}
|
||||
|
||||
private List<Row> rows(String database, String measurement, String targetTag, Instant from, Instant to)
|
||||
throws IOException {
|
||||
String query = "SELECT * FROM \"" + measurement + "\" WHERE time >= " + from.toEpochMilli()
|
||||
+ "ms AND time < " + to.toEpochMilli() + "ms ORDER BY time DESC";
|
||||
var rows = new ArrayList<Row>();
|
||||
for (JsonNode result : results(influx.query(database, query))) {
|
||||
for (JsonNode series : result.path("series")) {
|
||||
for (JsonNode values : series.path("values")) {
|
||||
rows.add(row(series.path("columns"), values, targetTag));
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static JsonNode results(String body) throws IOException {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = JSON.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
}
|
||||
failOnError(root);
|
||||
for (JsonNode result : root.path("results")) {
|
||||
failOnError(result);
|
||||
}
|
||||
return root.path("results");
|
||||
}
|
||||
|
||||
private static void failOnError(JsonNode node) throws IOException {
|
||||
if (node.hasNonNull("error")) {
|
||||
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||
}
|
||||
}
|
||||
|
||||
private static Row row(JsonNode columns, JsonNode values, String targetTag) {
|
||||
Map<String, JsonNode> byName = new HashMap<>();
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
byName.put(columns.get(i).asText(), values.get(i));
|
||||
}
|
||||
return new Row(Instant.ofEpochMilli(byName.get("time").asLong()), text(byName.get(targetTag)),
|
||||
text(byName.get("requested_by")), text(byName.get("status")),
|
||||
number(byName.get("old_value")), number(byName.get("new_value")));
|
||||
}
|
||||
|
||||
private static String text(JsonNode node) {
|
||||
return node == null || node.isNull() ? "" : node.asText();
|
||||
}
|
||||
|
||||
private static Double number(JsonNode node) {
|
||||
return node == null || !node.isNumber() ? null : node.asDouble();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Read-only queries to InfluxDB 1.x over its HTTP {@code /query} endpoint; times come back in epoch ms. */
|
||||
final class InfluxClient {
|
||||
|
||||
private final InfluxSettings settings;
|
||||
private final HttpClient http;
|
||||
|
||||
InfluxClient(InfluxSettings settings) {
|
||||
this.settings = settings;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
||||
}
|
||||
|
||||
String query(String database, String query) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
|
||||
.timeout(settings.requestTimeout())
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private URI queryUri(String database, String query) {
|
||||
String base = settings.baseUri().toString().replaceAll("/+$", "");
|
||||
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import home.backend.application.HeatPumpSamples;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
@@ -20,14 +16,12 @@ import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
|
||||
public final class InfluxHistoryRepository implements HistoryRepository {
|
||||
public final class InfluxHistoryRepository implements HistoryRepository, HeatPumpSamples {
|
||||
|
||||
private final InfluxSettings settings;
|
||||
private final HttpClient http;
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxHistoryRepository(InfluxSettings settings) {
|
||||
this.settings = settings;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -57,39 +51,19 @@ public final class InfluxHistoryRepository implements HistoryRepository {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HeatPumpSample> samples(Instant from, Instant to) throws IOException {
|
||||
String query = InfluxSamples.query(from.toEpochMilli(), to.toEpochMilli());
|
||||
return InfluxSamples.parse(influx.query(SeriesSources.LEGACY, query));
|
||||
}
|
||||
|
||||
private Map<Long, Double> part(Source source, Instant from, Instant to, long stepMinutes) throws IOException {
|
||||
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
|
||||
return InfluxResponse.parse(fetch(source.database(), query));
|
||||
return InfluxResponse.parse(influx.query(source.database(), query));
|
||||
}
|
||||
|
||||
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
|
||||
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
|
||||
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
|
||||
}
|
||||
|
||||
private String fetch(String database, String query) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
|
||||
.timeout(settings.requestTimeout())
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private URI queryUri(String database, String query) {
|
||||
String base = settings.baseUri().toString().replaceAll("/+$", "");
|
||||
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import home.backend.application.PvEnergyRepository;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** Daily PV energy: the Deye's and the Fronius's own day counters, each read as the day's maximum. */
|
||||
public final class InfluxPvEnergy implements PvEnergyRepository {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||
private static final String DATABASE = "electricity";
|
||||
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxPvEnergy(InfluxSettings settings) {
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException {
|
||||
Map<LocalDate, Double> deye = dailyMax("deye", "pv_today_kwh", from, to);
|
||||
Map<LocalDate, Double> fronius = dailyMax("fronius", "energy_day_wh", from, to);
|
||||
Map<LocalDate, Double> total = new TreeMap<>();
|
||||
deye.forEach((day, kwh) -> {
|
||||
Double wh = fronius.get(day);
|
||||
if (wh != null) {
|
||||
total.put(day, kwh + wh / 1000);
|
||||
}
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
private Map<LocalDate, Double> dailyMax(String measurement, String field, LocalDate from, LocalDate to)
|
||||
throws IOException {
|
||||
String query = "SELECT max(\"" + field + "\") FROM \"" + measurement + "\" WHERE time >= "
|
||||
+ millis(from) + "ms AND time < " + millis(to) + "ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')";
|
||||
Map<LocalDate, Double> days = new TreeMap<>();
|
||||
InfluxResponse.parse(influx.query(DATABASE, query)).forEach((start, max) ->
|
||||
days.put(Instant.ofEpochMilli(start).atZone(ZONE).toLocalDate(), max));
|
||||
return days;
|
||||
}
|
||||
|
||||
private static long millis(LocalDate day) {
|
||||
return day.atStartOfDay(ZONE).toInstant().toEpochMilli();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.Periods;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** The raw {@code heat_pump} rows of an InfluxDB 1.x query. */
|
||||
final class InfluxSamples {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private InfluxSamples() {
|
||||
}
|
||||
|
||||
static String query(long fromMs, long toMs) {
|
||||
return "SELECT output_compressor, output_cwu, output_co, 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";
|
||||
}
|
||||
|
||||
/** The rows of {@link #query}, as returned (ascending); empty when there is no data. */
|
||||
static List<HeatPumpSample> parse(String body) throws IOException {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = JSON.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
}
|
||||
failOnError(root);
|
||||
List<HeatPumpSample> samples = new ArrayList<>();
|
||||
for (JsonNode result : root.path("results")) {
|
||||
failOnError(result);
|
||||
for (JsonNode series : result.path("series")) {
|
||||
for (JsonNode row : series.path("values")) {
|
||||
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
|
||||
number(row, 1), number(row, 2), number(row, 3),
|
||||
number(row, 4), number(row, 5), periods(row)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
private static Periods periods(JsonNode row) {
|
||||
var targets = new Targets(number(row, 7), null, number(row, 8));
|
||||
var setpoints = new Setpoints(whole(row, 9), whole(row, 10), whole(row, 11), whole(row, 12));
|
||||
return Periods.of(whole(row, 6), targets, setpoints);
|
||||
}
|
||||
|
||||
private static Integer whole(JsonNode row, int column) {
|
||||
Double value = number(row, column);
|
||||
return value == null ? null : (int) Math.round(value);
|
||||
}
|
||||
|
||||
private static Double number(JsonNode row, int column) {
|
||||
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
|
||||
}
|
||||
|
||||
private static void failOnError(JsonNode node) throws IOException {
|
||||
if (node.hasNonNull("error")) {
|
||||
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import java.util.Map;
|
||||
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
|
||||
final class SeriesSources {
|
||||
|
||||
private static final String LEGACY = "Dom_Pompa";
|
||||
static final String LEGACY = "Dom_Pompa";
|
||||
private static final String ELECTRICITY = "electricity";
|
||||
|
||||
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
|
||||
/** GET of a public JSON document, with a timeout on connecting and on the whole request. */
|
||||
final class HttpJson {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private final HttpClient http;
|
||||
private final Duration timeout;
|
||||
|
||||
HttpJson(Duration timeout) {
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
JsonNode get(URI uri) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(uri).timeout(timeout).GET().build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException(uri.getHost() + " answered HTTP " + response.statusCode());
|
||||
}
|
||||
return JSON.readTree(response.body());
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException(uri.getHost() + " returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("request to " + uri.getHost() + " interrupted", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import home.backend.application.WarningSource;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* IMGW's public list of meteorological warnings, kept to those naming one of our TERYT areas. A warning
|
||||
* that is malformed (no degree or times) is left out rather than failing the others.
|
||||
*/
|
||||
public final class ImgwClient implements WarningSource {
|
||||
|
||||
private static final ZoneId POLAND = ZoneId.of("Europe/Warsaw");
|
||||
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final HttpJson http;
|
||||
private final URI uri;
|
||||
private final List<String> teryt;
|
||||
|
||||
public ImgwClient(URI baseUri, WeatherSettings place, Duration timeout) {
|
||||
this.http = new HttpJson(timeout);
|
||||
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/api/data/warningsmeteo");
|
||||
this.teryt = place.teryt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Warning> warnings() throws IOException {
|
||||
JsonNode root = http.get(uri);
|
||||
if (!root.isArray()) {
|
||||
throw new IOException("IMGW returned no list of warnings");
|
||||
}
|
||||
List<Warning> ours = new ArrayList<>();
|
||||
for (JsonNode item : root) {
|
||||
if (covers(item.path("teryt"))) {
|
||||
warning(item).ifPresent(ours::add);
|
||||
}
|
||||
}
|
||||
return ours;
|
||||
}
|
||||
|
||||
private boolean covers(JsonNode codes) {
|
||||
for (JsonNode code : codes) {
|
||||
if (teryt.contains(code.asText())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Optional<Warning> warning(JsonNode item) {
|
||||
try {
|
||||
return Optional.of(new Warning(item.path("nazwa_zdarzenia").asText(),
|
||||
Integer.parseInt(item.path("stopien").asText()),
|
||||
time(item.path("obowiazuje_od")), time(item.path("obowiazuje_do")),
|
||||
item.path("tresc").asText()));
|
||||
} catch (NumberFormatException | DateTimeException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static OffsetDateTime time(JsonNode text) {
|
||||
return LocalDateTime.parse(text.asText(), TIME).atZone(POLAND).toOffsetDateTime();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import home.backend.application.ForecastSource;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.Forecast.DailyForecast;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 4, and the temperature now. */
|
||||
public final class OpenMeteoClient implements ForecastSource {
|
||||
|
||||
private static final String DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,"
|
||||
+ "shortwave_radiation_sum,sunshine_duration";
|
||||
|
||||
private final HttpJson http;
|
||||
private final URI uri;
|
||||
|
||||
public OpenMeteoClient(URI baseUri, WeatherSettings place, Duration timeout) {
|
||||
this.http = new HttpJson(timeout);
|
||||
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/v1/forecast?latitude="
|
||||
+ place.latitude() + "&longitude=" + place.longitude() + "&daily=" + DAILY
|
||||
+ "¤t=temperature_2m&timezone=Europe%2FWarsaw&past_days=14&forecast_days=4");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Forecast forecast() throws IOException {
|
||||
JsonNode root = http.get(uri);
|
||||
JsonNode daily = root.path("daily");
|
||||
JsonNode dates = daily.path("time");
|
||||
if (!dates.isArray() || dates.isEmpty()) {
|
||||
throw new IOException("Open-Meteo returned no daily forecast");
|
||||
}
|
||||
Map<LocalDate, DailyForecast> days = new TreeMap<>();
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
days.put(date(dates.get(i).asText()), new DailyForecast(
|
||||
integer(daily, "weather_code", i), number(daily, "temperature_2m_max", i),
|
||||
number(daily, "temperature_2m_min", i), number(daily, "precipitation_sum", i),
|
||||
number(daily, "shortwave_radiation_sum", i), number(daily, "sunshine_duration", i)));
|
||||
}
|
||||
JsonNode now = root.path("current").path("temperature_2m");
|
||||
return new Forecast(now.isNumber() ? now.asDouble() : null, days);
|
||||
}
|
||||
|
||||
private static LocalDate date(String text) throws IOException {
|
||||
try {
|
||||
return LocalDate.parse(text);
|
||||
} catch (DateTimeException e) {
|
||||
throw new IOException("Open-Meteo returned a bad date: " + text, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Double number(JsonNode daily, String name, int i) {
|
||||
JsonNode value = daily.path(name).path(i);
|
||||
return value.isNumber() ? value.asDouble() : null;
|
||||
}
|
||||
|
||||
private static Integer integer(JsonNode daily, String name, int i) {
|
||||
JsonNode value = daily.path(name).path(i);
|
||||
return value.isNumber() ? value.asInt() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Where the house is and which IMGW areas (TERYT powiat codes) count as ours. Krosno is a city with
|
||||
* powiat rights, 1862; 1807 is the surrounding powiat krośnieński, which warnings may name instead.
|
||||
*/
|
||||
public record WeatherSettings(double latitude, double longitude, List<String> teryt) {
|
||||
|
||||
public static WeatherSettings fromEnvironment(Map<String, String> env) {
|
||||
return new WeatherSettings(
|
||||
coordinate(env, "WEATHER_LAT", 49.684, 90),
|
||||
coordinate(env, "WEATHER_LON", 21.770, 180),
|
||||
List.of(env.getOrDefault("WEATHER_TERYT", "1862,1807").strip().split("\\s*,\\s*")));
|
||||
}
|
||||
|
||||
private static double coordinate(Map<String, String> env, String name, double fallback, double limit) {
|
||||
String value = env.get(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
double number = Double.parseDouble(value.strip());
|
||||
if (Math.abs(number) <= limit) {
|
||||
return number;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// reported below with the same message
|
||||
}
|
||||
throw new IllegalArgumentException(name + " must be a number from -" + limit + " to " + limit
|
||||
+ ", got: " + value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** The gateway's audit rows in InfluxDB (S3), read only. */
|
||||
public interface AuditRepository {
|
||||
|
||||
/** One audited write as recorded; {@code target} is the setpoint name or the Deye register. */
|
||||
record Row(Instant time, String target, String requestedBy, String status, Double oldValue, Double newValue) {
|
||||
}
|
||||
|
||||
List<Row> heatPump(Instant from, Instant to) throws IOException;
|
||||
|
||||
List<Row> battery(Instant from, Instant to) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.AuditRepository.Row;
|
||||
import home.backend.domain.AuditEntry;
|
||||
import home.backend.domain.AuditView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** S3: who changed what and when, from the gateway's audit rows of the heat pump and of the battery. */
|
||||
public class AuditService {
|
||||
|
||||
private static final String DEFAULT_RANGE = "7d";
|
||||
private static final Map<String, Duration> RANGES =
|
||||
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7), "30d", Duration.ofDays(30));
|
||||
private static final Map<String, String> SETPOINTS = Map.of(
|
||||
"cwu_economy", "Ciepła woda – oszczędnie", "cwu_comfort", "Ciepła woda – komfort",
|
||||
"co_economy", "Ogrzewanie – oszczędnie", "co_comfort", "Ogrzewanie – komfort");
|
||||
private static final String RESTORE_USER = "restore";
|
||||
private static final String RESTORE_LABEL = "automatycznie (przywrócenie)";
|
||||
|
||||
private final AuditRepository repository;
|
||||
private final Clock clock;
|
||||
|
||||
public AuditService(AuditRepository repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param range {@code 24h}, {@code 7d} or {@code 30d}; null means 7d
|
||||
* @throws IllegalArgumentException for an unknown range
|
||||
*/
|
||||
public AuditView audit(String range) throws IOException {
|
||||
Duration length = RANGES.get(range == null ? DEFAULT_RANGE : range);
|
||||
if (length == null) {
|
||||
throw new IllegalArgumentException("unknown range: " + range);
|
||||
}
|
||||
Instant to = clock.instant();
|
||||
Instant from = to.minus(length);
|
||||
var heatPump = repository.heatPump(from, to).stream().map(AuditService::heatPumpEntry);
|
||||
var battery = repository.battery(from, to).stream().map(AuditService::batteryEntry);
|
||||
return new AuditView(Stream.concat(heatPump, battery)
|
||||
.sorted(Comparator.comparing(AuditEntry::time).reversed())
|
||||
.toList());
|
||||
}
|
||||
|
||||
private static AuditEntry heatPumpEntry(Row row) {
|
||||
return entry(AuditEntry.HEAT_PUMP, row, SETPOINTS.getOrDefault(row.target(), row.target()), "°C");
|
||||
}
|
||||
|
||||
private static AuditEntry batteryEntry(Row row) {
|
||||
var label = DeyeSettingLabels.of(row.target());
|
||||
return entry(AuditEntry.BATTERY, row, label.what(), label.unit());
|
||||
}
|
||||
|
||||
private static AuditEntry entry(String source, Row row, String what, String unit) {
|
||||
return new AuditEntry(row.time(), source, what, whole(row.oldValue()), whole(row.newValue()), unit,
|
||||
RESTORE_USER.equals(row.requestedBy()) ? RESTORE_LABEL : row.requestedBy(), row.status());
|
||||
}
|
||||
|
||||
private static Integer whole(Double value) {
|
||||
return value == null ? null : (int) Math.round(value);
|
||||
}
|
||||
}
|
||||
@@ -86,8 +86,13 @@ public class BatteryService {
|
||||
: "Rezerwa na awarię: bateria nie rozładowuje się na co dzień");
|
||||
}
|
||||
|
||||
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first. */
|
||||
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first.
|
||||
* No schedule at all when a start is missing. */
|
||||
private static List<Slot> schedule(Map<String, Double> s) {
|
||||
boolean complete = IntStream.rangeClosed(1, SLOTS).allMatch(n -> whole(s, "prog" + n + "_start_hhmm") != null);
|
||||
if (!complete) {
|
||||
return List.of();
|
||||
}
|
||||
return IntStream.rangeClosed(1, SLOTS).mapToObj(n -> {
|
||||
String prefix = "prog" + n + "_";
|
||||
String next = "prog" + (n % SLOTS + 1) + "_start_hhmm";
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* One remote answer kept for a while, and no longer than its local day when a zone is given. When a refresh
|
||||
* fails the older answer is served marked stale, and the source is left alone for a short back-off so a
|
||||
* source that is down does not make every request wait for its timeout.
|
||||
*/
|
||||
final class Cached<T> {
|
||||
|
||||
interface Fetch<T> {
|
||||
T fetch() throws IOException;
|
||||
}
|
||||
|
||||
record Entry<T>(T value, boolean stale) {
|
||||
}
|
||||
|
||||
private static final Duration BACK_OFF = Duration.ofSeconds(60);
|
||||
|
||||
private final Duration ttl;
|
||||
private final ZoneId dayZone;
|
||||
private final Clock clock;
|
||||
private final Fetch<T> source;
|
||||
private T value;
|
||||
private Instant fetchedAt;
|
||||
private Instant failedAt;
|
||||
|
||||
Cached(Duration ttl, Clock clock, Fetch<T> source) {
|
||||
this(ttl, null, clock, source);
|
||||
}
|
||||
|
||||
/** @param dayZone the answer expires at midnight of this zone even before the ttl, or null */
|
||||
Cached(Duration ttl, ZoneId dayZone, Clock clock, Fetch<T> source) {
|
||||
this.ttl = ttl;
|
||||
this.dayZone = dayZone;
|
||||
this.clock = clock;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/** @throws IOException when the refresh fails or is backing off, and there is nothing older to serve */
|
||||
synchronized Entry<T> get() throws IOException {
|
||||
Instant now = clock.instant();
|
||||
if (value != null && now.isBefore(expiry())) {
|
||||
return new Entry<>(value, false);
|
||||
}
|
||||
if (failedAt != null && now.isBefore(failedAt.plus(BACK_OFF))) {
|
||||
return older(new IOException("source failed recently, not retrying yet"));
|
||||
}
|
||||
try {
|
||||
value = source.fetch();
|
||||
fetchedAt = now;
|
||||
failedAt = null;
|
||||
return new Entry<>(value, false);
|
||||
} catch (IOException e) {
|
||||
failedAt = now;
|
||||
return older(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Entry<T> older(IOException failure) throws IOException {
|
||||
if (value == null) {
|
||||
throw failure;
|
||||
}
|
||||
return new Entry<>(value, true);
|
||||
}
|
||||
|
||||
private Instant expiry() {
|
||||
Instant byTtl = fetchedAt.plus(ttl);
|
||||
if (dayZone == null) {
|
||||
return byTtl;
|
||||
}
|
||||
Instant midnight = fetchedAt.atZone(dayZone).toLocalDate().plusDays(1).atStartOfDay(dayZone).toInstant();
|
||||
return midnight.isBefore(byTtl) ? midnight : byTtl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.StatsView.Day;
|
||||
import home.backend.domain.StatsView.Totals;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** What the heat pump did over some span of polls; days are added up into the totals. */
|
||||
final class DayTally {
|
||||
|
||||
private double compressorSeconds;
|
||||
private double cwuSeconds;
|
||||
private double coSeconds;
|
||||
private int starts;
|
||||
private int samples;
|
||||
private double outdoorSum;
|
||||
private int outdoorCount;
|
||||
private Double minCwu;
|
||||
private Double maxCwu;
|
||||
|
||||
void sample(HeatPumpSample s) {
|
||||
samples++;
|
||||
if (s.outdoorTemp() != null) {
|
||||
outdoorSum += s.outdoorTemp();
|
||||
outdoorCount++;
|
||||
}
|
||||
if (s.cwuTemp() != null) {
|
||||
minCwu = lower(minCwu, s.cwuTemp());
|
||||
maxCwu = higher(maxCwu, s.cwuTemp());
|
||||
}
|
||||
}
|
||||
|
||||
/** The outputs of {@code s} held for {@code held}. */
|
||||
void hold(HeatPumpSample s, Duration held) {
|
||||
double seconds = held.toMillis() / 1000.0;
|
||||
compressorSeconds += on(s.compressor()) ? seconds : 0;
|
||||
cwuSeconds += on(s.cwu()) ? seconds : 0;
|
||||
coSeconds += on(s.co()) ? seconds : 0;
|
||||
}
|
||||
|
||||
void start() {
|
||||
starts++;
|
||||
}
|
||||
|
||||
void add(DayTally other) {
|
||||
compressorSeconds += other.compressorSeconds;
|
||||
cwuSeconds += other.cwuSeconds;
|
||||
coSeconds += other.coSeconds;
|
||||
starts += other.starts;
|
||||
samples += other.samples;
|
||||
outdoorSum += other.outdoorSum;
|
||||
outdoorCount += other.outdoorCount;
|
||||
if (other.minCwu != null) {
|
||||
minCwu = lower(minCwu, other.minCwu);
|
||||
maxCwu = higher(maxCwu, other.maxCwu);
|
||||
}
|
||||
}
|
||||
|
||||
Day day(LocalDate date) {
|
||||
if (samples == 0) {
|
||||
return new Day(date, null, null, null, null, null, null, null);
|
||||
}
|
||||
return new Day(date, hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
||||
avgOutdoor(), minCwu, maxCwu);
|
||||
}
|
||||
|
||||
Totals totals() {
|
||||
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
||||
avgOutdoor(), minCwu, maxCwu);
|
||||
}
|
||||
|
||||
static boolean on(Double output) {
|
||||
return output != null && output > 0;
|
||||
}
|
||||
|
||||
private static Double lower(Double known, double value) {
|
||||
return known == null ? value : Math.min(known, value);
|
||||
}
|
||||
|
||||
private static Double higher(Double known, double value) {
|
||||
return known == null ? value : Math.max(known, value);
|
||||
}
|
||||
|
||||
private Double avgOutdoor() {
|
||||
return outdoorCount == 0 ? null : Math.round(outdoorSum / outdoorCount * 10) / 10.0;
|
||||
}
|
||||
|
||||
private static double hours(double seconds) {
|
||||
return Math.round(seconds / 3600 * 100) / 100.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/** Polish name and unit of the Deye program registers and of the app's reserve, as the gateway audits them. */
|
||||
final class DeyeSettingLabels {
|
||||
|
||||
record Label(String what, String unit) {
|
||||
}
|
||||
|
||||
private static final int TOU_ENABLE = 146;
|
||||
private static final int FIRST_START = 148;
|
||||
private static final int FIRST_SOC = 166;
|
||||
private static final int FIRST_GRID_CHARGE = 172;
|
||||
private static final int SLOTS = 6;
|
||||
|
||||
private DeyeSettingLabels() {
|
||||
}
|
||||
|
||||
static Label of(String register) {
|
||||
if ("reserve".equals(register)) {
|
||||
return new Label("Rezerwa baterii", "%");
|
||||
}
|
||||
int address;
|
||||
try {
|
||||
address = Integer.parseInt(register);
|
||||
} catch (NumberFormatException e) {
|
||||
return unknown(register);
|
||||
}
|
||||
if (address == TOU_ENABLE) {
|
||||
return new Label("Harmonogram baterii", "0/1");
|
||||
}
|
||||
return slot(address, FIRST_START, "start", "HHMM")
|
||||
.or(() -> slot(address, FIRST_SOC, "poziom", "%"))
|
||||
.or(() -> slot(address, FIRST_GRID_CHARGE, "ładowanie z sieci", "0/1"))
|
||||
.orElseGet(() -> unknown(register));
|
||||
}
|
||||
|
||||
private static Label unknown(String register) {
|
||||
return new Label("Rejestr " + register, "");
|
||||
}
|
||||
|
||||
private static Optional<Label> slot(int address, int first, String what, String unit) {
|
||||
return address >= first && address < first + SLOTS
|
||||
? Optional.of(new Label("Przedział " + (address - first + 1) + ": " + what, unit))
|
||||
: Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ public class EnergyDetailsService {
|
||||
phases("grid_power_l%d_w", "Moc", "W", 0),
|
||||
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
|
||||
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
|
||||
group("Odbiór domu",
|
||||
group("Wyjście zapasowe falownika",
|
||||
field("load_power_w", "Moc razem", "W", 0),
|
||||
phases("load_power_l%d_w", "Moc", "W", 0)),
|
||||
group("Panele PV",
|
||||
@@ -81,14 +81,14 @@ public class EnergyDetailsService {
|
||||
field("pv2_current_a", "PV2 prąd", "A", 1)),
|
||||
group("Dziś",
|
||||
field("pv_today_kwh", "Produkcja PV", "kWh", 1),
|
||||
field("load_today_kwh", "Zużycie domu", "kWh", 1),
|
||||
field("load_today_kwh", "Wyjście zapasowe", "kWh", 1),
|
||||
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
|
||||
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
|
||||
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
|
||||
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
|
||||
group("Od początku",
|
||||
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
|
||||
field("load_total_kwh", "Zużycie domu", "kWh", 0),
|
||||
field("load_total_kwh", "Wyjście zapasowe", "kWh", 0),
|
||||
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
|
||||
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
|
||||
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
|
||||
|
||||
@@ -11,9 +11,20 @@ import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
|
||||
/**
|
||||
* F7: house electricity from the Deye inverter, the Fronius PV inverter and the house meter.
|
||||
* The Deye "load" registers cover only its backup output, so the house is a balance:
|
||||
* <ul>
|
||||
* <li>now = Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, where grid is the
|
||||
* house meter's total active power when it is live, else the Deye's grid power; floored at 0, null when
|
||||
* any of those readings is missing or stale;</li>
|
||||
* <li>today = Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged
|
||||
* (Deye day counters), null when any is missing.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class EnergyService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
@@ -30,17 +41,42 @@ public class EnergyService {
|
||||
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
|
||||
Map<String, Double> d = deye.fields();
|
||||
Map<String, Double> f = fronius.fields();
|
||||
Map<String, Double> meter = SnapshotFields.live(snapshot, "house-meter", "PRAD2", clock.instant()).fields();
|
||||
|
||||
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
|
||||
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
|
||||
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), d.get("load_today_kwh"));
|
||||
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), houseKwh(d, froniusKwh));
|
||||
List<Source> sources = List.of(deye.source(), fronius.source());
|
||||
return new EnergyView(oldest(sources), sources,
|
||||
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
|
||||
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
|
||||
housePowerW(d, f, meter.getOrDefault("total_power_active", d.get("grid_power_w"))),
|
||||
d.get("load_power_w"), today);
|
||||
}
|
||||
|
||||
private static Double housePowerW(Map<String, Double> deye, Map<String, Double> fronius, Double gridW) {
|
||||
Double pv1 = deye.get("pv1_power_w");
|
||||
Double pv2 = deye.get("pv2_power_w");
|
||||
Double pvFronius = fronius.get("pv_power_w");
|
||||
Double battery = deye.get("battery_power_w");
|
||||
if (Stream.of(pv1, pv2, pvFronius, battery, gridW).anyMatch(Objects::isNull)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, pv1 + pv2 + pvFronius + battery + gridW);
|
||||
}
|
||||
|
||||
private static Double houseKwh(Map<String, Double> deye, Double froniusKwh) {
|
||||
Double pv = deye.get("pv_today_kwh");
|
||||
Double bought = deye.get("grid_bought_today_kwh");
|
||||
Double sold = deye.get("grid_sold_today_kwh");
|
||||
Double discharged = deye.get("battery_discharge_today_kwh");
|
||||
Double charged = deye.get("battery_charge_today_kwh");
|
||||
if (Stream.of(pv, froniusKwh, bought, sold, discharged, charged).anyMatch(Objects::isNull)) {
|
||||
return null;
|
||||
}
|
||||
return round(Math.max(0, pv + froniusKwh + bought - sold + discharged - charged));
|
||||
}
|
||||
|
||||
private static Instant oldest(List<Source> sources) {
|
||||
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Forecast;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** The weather forecast service: the past 14 days and the next 3. */
|
||||
public interface ForecastSource {
|
||||
|
||||
Forecast forecast() throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** The heat pump polls stored in InfluxDB, oldest first. */
|
||||
public interface HeatPumpSamples {
|
||||
|
||||
List<HeatPumpSample> samples(Instant from, Instant to) throws IOException;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.HeatPumpView.Temperatures;
|
||||
import home.backend.domain.Periods;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
@@ -43,7 +44,11 @@ public class HeatPumpService {
|
||||
|
||||
private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) {
|
||||
Integer alarmCode = whole(f, "alarm_code");
|
||||
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
|
||||
var targets = new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
|
||||
tenths(f, "target_cwu_temp"));
|
||||
var setpoints = new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
|
||||
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort"));
|
||||
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"),
|
||||
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
|
||||
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
|
||||
on(f, "output_ground"), on(f, "output_cwu")),
|
||||
@@ -51,10 +56,7 @@ public class HeatPumpService {
|
||||
tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
|
||||
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
|
||||
tenths(f, "outdoor_avg_temp")),
|
||||
new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
|
||||
tenths(f, "target_cwu_temp")),
|
||||
new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
|
||||
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort")));
|
||||
targets, setpoints, Periods.of(whole(f, "program"), targets, setpoints));
|
||||
}
|
||||
|
||||
/** Codes of register 4011 as the vendor app names them; unverified on our device. */
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.Period;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.ProgramView;
|
||||
import home.backend.domain.ProgramView.Circuit;
|
||||
import home.backend.domain.ProgramView.Confidence;
|
||||
import home.backend.domain.ProgramView.Interval;
|
||||
import home.backend.domain.ProgramView.Weekday;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* F6: the weekly program as the history shows it, without asking the controller (its program
|
||||
* registers sit behind a shared selector, so reading them would mean writing). A transition between
|
||||
* two adjacent polls, economy to comfort or back, puts a comfort boundary halfway between them; a
|
||||
* poll in another period (override, or setpoints that make the periods ambiguous, for instance while
|
||||
* we write one) breaks the chain, as does a gap in the data. Times are local, so a day with a change
|
||||
* of time needs no special case. Today is left out: it is not over.
|
||||
*/
|
||||
public class ProgramService {
|
||||
|
||||
private static final int DEFAULT_DAYS = 14;
|
||||
private static final int MIN_DAYS = 7;
|
||||
private static final int MAX_DAYS = 30;
|
||||
private static final Duration MAX_GAP = StatsService.MAX_HOLD;
|
||||
private static final int STEP_MINUTES = 5;
|
||||
private static final int MINUTES_PER_DAY = 24 * 60;
|
||||
|
||||
private record Span(int from, int to) {
|
||||
}
|
||||
|
||||
/** What the history says about one circuit: days with a known period and their comfort spans. */
|
||||
private record Observed(Set<LocalDate> days, Map<LocalDate, List<Span>> comfort) {
|
||||
}
|
||||
|
||||
private final HeatPumpSamples repository;
|
||||
private final Clock clock;
|
||||
|
||||
public ProgramService(HeatPumpSamples repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param days how many past days to read, 7 to 30; null means 14
|
||||
* @throws IllegalArgumentException for anything else
|
||||
*/
|
||||
public ProgramView program(String days) throws IOException {
|
||||
int length = length(days);
|
||||
LocalDate today = clock.instant().atZone(StatsService.ZONE).toLocalDate();
|
||||
List<HeatPumpSample> samples = repository.samples(
|
||||
today.minusDays(length).atStartOfDay(StatsService.ZONE).toInstant(),
|
||||
today.atStartOfDay(StatsService.ZONE).toInstant());
|
||||
Observed cwu = observe(samples, Periods::cwu);
|
||||
Observed room = observe(samples, Periods::room);
|
||||
List<Weekday> weekdays = new ArrayList<>();
|
||||
for (int day = 1; day <= 7; day++) {
|
||||
weekdays.add(new Weekday(day, circuit(cwu, day), circuit(room, day)));
|
||||
}
|
||||
return new ProgramView(length, weekdays);
|
||||
}
|
||||
|
||||
private static int length(String days) {
|
||||
if (days == null) {
|
||||
return DEFAULT_DAYS;
|
||||
}
|
||||
try {
|
||||
int length = Integer.parseInt(days);
|
||||
if (length >= MIN_DAYS && length <= MAX_DAYS) {
|
||||
return length;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// reported below
|
||||
}
|
||||
throw new IllegalArgumentException("days must be " + MIN_DAYS + " to " + MAX_DAYS);
|
||||
}
|
||||
|
||||
private static Observed observe(List<HeatPumpSample> samples, Function<Periods, Period> circuit) {
|
||||
Set<LocalDate> days = new TreeSet<>();
|
||||
Map<LocalDate, List<Span>> comfort = new TreeMap<>();
|
||||
Period previous = null;
|
||||
Instant previousTime = null;
|
||||
Instant comfortStart = null;
|
||||
for (HeatPumpSample sample : samples) {
|
||||
Period period = circuit.apply(sample.periods());
|
||||
if (period != Period.COMFORT && period != Period.ECONOMY) {
|
||||
previous = null;
|
||||
comfortStart = null;
|
||||
continue;
|
||||
}
|
||||
days.add(sample.t().atZone(StatsService.ZONE).toLocalDate());
|
||||
boolean adjacent = previous != null && Duration.between(previousTime, sample.t()).compareTo(MAX_GAP) <= 0;
|
||||
if (!adjacent) {
|
||||
comfortStart = null;
|
||||
} else if (period != previous) {
|
||||
Instant boundary = previousTime.plus(Duration.between(previousTime, sample.t()).dividedBy(2));
|
||||
if (period == Period.COMFORT) {
|
||||
comfortStart = boundary;
|
||||
} else if (comfortStart != null) {
|
||||
addSpans(comfort, comfortStart, boundary);
|
||||
comfortStart = null;
|
||||
}
|
||||
}
|
||||
previous = period;
|
||||
previousTime = sample.t();
|
||||
}
|
||||
return new Observed(days, comfort);
|
||||
}
|
||||
|
||||
/** Splits at local midnight; a start at 23:58 and an end at 00:03 are rounded before that. */
|
||||
private static void addSpans(Map<LocalDate, List<Span>> comfort, Instant from, Instant to) {
|
||||
LocalDateTime start = rounded(from.atZone(StatsService.ZONE).toLocalDateTime());
|
||||
LocalDateTime end = rounded(to.atZone(StatsService.ZONE).toLocalDateTime());
|
||||
for (LocalDate date = start.toLocalDate(); !date.isAfter(end.toLocalDate()); date = date.plusDays(1)) {
|
||||
int spanStart = date.equals(start.toLocalDate()) ? minutes(start) : 0;
|
||||
int spanEnd = date.equals(end.toLocalDate()) ? minutes(end) : MINUTES_PER_DAY;
|
||||
if (spanEnd > spanStart) {
|
||||
comfort.computeIfAbsent(date, d -> new ArrayList<>()).add(new Span(spanStart, spanEnd));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDateTime rounded(LocalDateTime time) {
|
||||
long steps = Math.round((time.getMinute() * 60 + time.getSecond()) / (STEP_MINUTES * 60.0));
|
||||
return time.truncatedTo(ChronoUnit.HOURS).plusMinutes(steps * STEP_MINUTES);
|
||||
}
|
||||
|
||||
private static int minutes(LocalDateTime time) {
|
||||
return time.getHour() * 60 + time.getMinute();
|
||||
}
|
||||
|
||||
/**
|
||||
* The pattern most days of this weekday agree on (the latest wins a tie); {@code CONFIRMED} when at
|
||||
* least two days show it.
|
||||
*/
|
||||
private static Circuit circuit(Observed observed, int weekday) {
|
||||
List<List<Span>> patterns = observed.days().stream()
|
||||
.filter(date -> date.getDayOfWeek().getValue() == weekday)
|
||||
.map(date -> observed.comfort().getOrDefault(date, List.of()))
|
||||
.toList();
|
||||
if (patterns.isEmpty()) {
|
||||
return new Circuit(Confidence.NO_DATA, 0, 0, List.of());
|
||||
}
|
||||
List<Span> best = null;
|
||||
int bestCount = 0;
|
||||
for (List<Span> pattern : patterns) {
|
||||
int count = (int) patterns.stream().filter(other -> agree(pattern, other)).count();
|
||||
if (count >= bestCount) {
|
||||
best = pattern;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return new Circuit(bestCount >= 2 ? Confidence.CONFIRMED : Confidence.SEEN_ONCE, patterns.size(), bestCount,
|
||||
best.stream().map(ProgramService::interval).toList());
|
||||
}
|
||||
|
||||
/** Equal within one step at both ends of every span. */
|
||||
private static boolean agree(List<Span> a, List<Span> b) {
|
||||
if (a.size() != b.size()) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < a.size(); i++) {
|
||||
if (Math.abs(a.get(i).from() - b.get(i).from()) > STEP_MINUTES
|
||||
|| Math.abs(a.get(i).to() - b.get(i).to()) > STEP_MINUTES) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Interval interval(Span span) {
|
||||
return new Interval(hhmm(span.from()), hhmm(span.to()));
|
||||
}
|
||||
|
||||
private static String hhmm(int minutes) {
|
||||
return String.format("%02d:%02d", minutes / 60, minutes % 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/** What the panels produced, from InfluxDB. */
|
||||
public interface PvEnergyRepository {
|
||||
|
||||
/** Deye plus Fronius kWh per local day from {@code from} up to {@code to}; days missing either are absent. */
|
||||
Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException;
|
||||
}
|
||||
@@ -25,6 +25,16 @@ import java.util.stream.Collectors;
|
||||
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
||||
public class SetpointService {
|
||||
|
||||
/** Longest timed preset the user may choose; the gateway's own limit is higher. */
|
||||
public static final int MAX_PRESET_HOURS = 4;
|
||||
|
||||
/** The chosen hours are not a whole number from 1 to {@link #MAX_PRESET_HOURS}, or the preset is not timed. */
|
||||
public static class InvalidHoursException extends IllegalArgumentException {
|
||||
InvalidHoursException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
private final GatewayCommands gateway;
|
||||
private final List<Preset> presets;
|
||||
|
||||
@@ -53,12 +63,15 @@ public class SetpointService {
|
||||
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
|
||||
*
|
||||
* @param until the end the user chose; required by a held preset, not allowed for any other
|
||||
* @param hours how long a timed preset keeps its changes, 1 to {@link #MAX_PRESET_HOURS}; null keeps the preset's
|
||||
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
|
||||
* preset's value
|
||||
* @throws InvalidHoursException when {@code hours} is out of range or the preset has no hours to choose
|
||||
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
|
||||
* for a setpoint the preset does not change or outside the gateway's limits
|
||||
*/
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, Integer hours, Map<String, Integer> values,
|
||||
String user)
|
||||
throws IOException {
|
||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||
if (preset.isEmpty()) {
|
||||
@@ -68,7 +81,7 @@ public class SetpointService {
|
||||
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
|
||||
: "preset " + id + " has no end time to choose");
|
||||
}
|
||||
List<Change> changes = withValues(preset.get().changes(), values);
|
||||
List<Change> changes = withHours(withValues(preset.get().changes(), values), hours);
|
||||
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||
requireWithinLimits(values, limits);
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
|
||||
@@ -87,6 +100,19 @@ public class SetpointService {
|
||||
c.hours())).toList();
|
||||
}
|
||||
|
||||
private static List<Change> withHours(List<Change> changes, Integer hours) {
|
||||
if (hours == null) {
|
||||
return changes;
|
||||
}
|
||||
if (hours < 1 || hours > MAX_PRESET_HOURS) {
|
||||
throw new InvalidHoursException("hours must be 1 to " + MAX_PRESET_HOURS);
|
||||
}
|
||||
if (changes.stream().anyMatch(c -> c.hours() == null)) {
|
||||
throw new InvalidHoursException("this preset has no hours to choose");
|
||||
}
|
||||
return changes.stream().map(c -> new Change(c.setpoint(), c.value(), hours.doubleValue())).toList();
|
||||
}
|
||||
|
||||
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
|
||||
for (Setpoint limit : limits) {
|
||||
Integer value = values.get(limit.name());
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.StatsView;
|
||||
import home.backend.domain.StatsView.Day;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* F2: the heat pump's work per local day over the last 7 or 30 days. A poll is taken to hold its
|
||||
* outputs until the next poll, but at most {@link #MAX_HOLD}, so a gap in the data adds no hours.
|
||||
*/
|
||||
public class StatsService {
|
||||
|
||||
static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||
static final Duration MAX_HOLD = Duration.ofSeconds(600);
|
||||
private static final Map<String, Integer> RANGE_DAYS = Map.of("7d", 7, "30d", 30);
|
||||
|
||||
private final HeatPumpSamples repository;
|
||||
private final Clock clock;
|
||||
|
||||
public StatsService(HeatPumpSamples repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param range {@code 7d} or {@code 30d}
|
||||
* @throws IllegalArgumentException for an unknown range
|
||||
*/
|
||||
public StatsView stats(String range) throws IOException {
|
||||
Integer length = range == null ? null : RANGE_DAYS.get(range);
|
||||
if (length == null) {
|
||||
throw new IllegalArgumentException("unknown range: " + range);
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
LocalDate first = now.atZone(ZONE).toLocalDate().minusDays(length - 1);
|
||||
Map<LocalDate, DayTally> tallies = new TreeMap<>();
|
||||
for (int i = 0; i < length; i++) {
|
||||
tallies.put(first.plusDays(i), new DayTally());
|
||||
}
|
||||
tally(repository.samples(first.atStartOfDay(ZONE).toInstant(), now), now, tallies);
|
||||
|
||||
DayTally all = new DayTally();
|
||||
List<Day> days = new ArrayList<>();
|
||||
tallies.forEach((date, tally) -> {
|
||||
days.add(tally.day(date));
|
||||
all.add(tally);
|
||||
});
|
||||
return new StatsView(range, days, all.totals());
|
||||
}
|
||||
|
||||
private static void tally(List<HeatPumpSample> samples, Instant now, Map<LocalDate, DayTally> tallies) {
|
||||
HeatPumpSample previous = null;
|
||||
for (int i = 0; i < samples.size(); i++) {
|
||||
HeatPumpSample s = samples.get(i);
|
||||
DayTally day = tallies.get(s.t().atZone(ZONE).toLocalDate());
|
||||
day.sample(s);
|
||||
if (previous != null && previous.compressor() != null && previous.compressor() == 0
|
||||
&& DayTally.on(s.compressor())) {
|
||||
day.start();
|
||||
}
|
||||
previous = s;
|
||||
hold(s, i + 1 < samples.size() ? samples.get(i + 1).t() : now, tallies);
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds the time {@code s} was in force to the days it falls in; a stretch over midnight is split. */
|
||||
private static void hold(HeatPumpSample s, Instant next, Map<LocalDate, DayTally> tallies) {
|
||||
Instant end = Duration.between(s.t(), next).compareTo(MAX_HOLD) > 0 ? s.t().plus(MAX_HOLD) : next;
|
||||
Instant at = s.t();
|
||||
while (at.isBefore(end)) {
|
||||
LocalDate date = at.atZone(ZONE).toLocalDate();
|
||||
Instant midnight = date.plusDays(1).atStartOfDay(ZONE).toInstant();
|
||||
Instant stop = midnight.isBefore(end) ? midnight : end;
|
||||
tallies.get(date).hold(s, Duration.between(at, stop));
|
||||
at = stop;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/** Weather warnings that cover the house, whether in force now or announced. */
|
||||
public interface WarningSource {
|
||||
|
||||
List<Warning> warnings() throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.Cached.Entry;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.Forecast.DailyForecast;
|
||||
import home.backend.domain.PvCalibration;
|
||||
import home.backend.domain.WeatherCodes;
|
||||
import home.backend.domain.WeatherView;
|
||||
import home.backend.domain.WeatherView.Day;
|
||||
import home.backend.domain.WeatherView.Now;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Forecast, PV estimate and warnings, each kept for a while so the phones do not load the sources. */
|
||||
public class WeatherService {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||
private static final int CALIBRATION_DAYS = 14;
|
||||
|
||||
private record Calibrated(Forecast forecast, Double factor) {
|
||||
}
|
||||
|
||||
private final Clock clock;
|
||||
private final PvEnergyRepository pv;
|
||||
private final Cached<Calibrated> forecast;
|
||||
private final Cached<List<Warning>> warnings;
|
||||
|
||||
public WeatherService(ForecastSource forecastSource, WarningSource warningSource, PvEnergyRepository pv,
|
||||
Clock clock) {
|
||||
this.clock = clock;
|
||||
this.pv = pv;
|
||||
this.forecast = new Cached<>(Duration.ofMinutes(30), ZONE, clock, () -> calibrated(forecastSource.forecast()));
|
||||
this.warnings = new Cached<>(Duration.ofMinutes(10), clock, warningSource::warnings);
|
||||
}
|
||||
|
||||
/** @throws IOException when there is no forecast at all; warnings alone may be missing. */
|
||||
public WeatherView current() throws IOException {
|
||||
Entry<Calibrated> weather = forecast.get();
|
||||
Entry<List<Warning>> warned = warnings();
|
||||
LocalDate today = LocalDate.now(clock.withZone(ZONE));
|
||||
Calibrated w = weather.value();
|
||||
return new WeatherView(clock.instant(),
|
||||
day(w, today), day(w, today.plusDays(1)), day(w, today.plusDays(2)),
|
||||
new Now(w.forecast().temperature()), inForce(warned.value()), w.factor(),
|
||||
weather.stale() || warned.stale());
|
||||
}
|
||||
|
||||
private Entry<List<Warning>> warnings() {
|
||||
try {
|
||||
return warnings.get();
|
||||
} catch (IOException e) {
|
||||
return new Entry<>(List.of(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Warning> inForce(List<Warning> all) {
|
||||
return all.stream().filter(w -> w.to().toInstant().isAfter(clock.instant())).toList();
|
||||
}
|
||||
|
||||
private Calibrated calibrated(Forecast fetched) {
|
||||
LocalDate today = LocalDate.now(clock.withZone(ZONE));
|
||||
Map<LocalDate, Double> radiation = new HashMap<>();
|
||||
fetched.days().forEach((date, day) -> {
|
||||
if (day.radiationMj() != null) {
|
||||
radiation.put(date, day.radiationMj());
|
||||
}
|
||||
});
|
||||
try {
|
||||
var measured = pv.dailyKwh(today.minusDays(CALIBRATION_DAYS), today);
|
||||
return new Calibrated(fetched, PvCalibration.factor(measured, radiation));
|
||||
} catch (IOException e) {
|
||||
return new Calibrated(fetched, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static Day day(Calibrated w, LocalDate date) throws IOException {
|
||||
DailyForecast d = w.forecast().days().get(date);
|
||||
if (d == null) {
|
||||
throw new IOException("forecast has no " + date);
|
||||
}
|
||||
Double kwh = w.factor() == null || d.radiationMj() == null ? null : round(w.factor() * d.radiationMj());
|
||||
Double sunshine = d.sunshineSeconds() == null ? null : round(d.sunshineSeconds() / 3600);
|
||||
return new Day(date, d.weatherCode(), WeatherCodes.describe(d.weatherCode()), d.tMax(), d.tMin(),
|
||||
d.precipitationMm(), sunshine, kwh);
|
||||
}
|
||||
|
||||
private static double round(double value) {
|
||||
return Math.round(value * 10) / 10.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* One audited write. {@code unit} is "°C", "%", "HHMM" (a clock time written as 1830) or "0/1" (off/on);
|
||||
* the values are null when the gateway did not record them.
|
||||
*/
|
||||
public record AuditEntry(Instant time, String source, String what, Integer oldValue, Integer newValue,
|
||||
String unit, String who, String status) {
|
||||
|
||||
public static final String HEAT_PUMP = "heat_pump";
|
||||
public static final String BATTERY = "battery";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The audited writes of the heat pump and the battery, newest first. */
|
||||
public record AuditView(List<AuditEntry> entries) {
|
||||
}
|
||||
@@ -5,17 +5,18 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
|
||||
* − charge. Values that come only from a stale source are null. {@code time} is the oldest source
|
||||
* − charge. {@code housePowerW} is the whole house by balance; {@code backupOutputW} only the Deye's
|
||||
* backup (EPS) output. Values that come only from a stale source are null. {@code time} is the oldest source
|
||||
* time (null when none was read); {@code sources} lists Deye and Fronius with their own time and freshness.
|
||||
*/
|
||||
public record EnergyView(Instant time, List<Source> sources, Double pvPowerW, Double gridPowerW,
|
||||
Double batteryPowerW, Double batterySocPct, Double loadPowerW, Today today) {
|
||||
Double batteryPowerW, Double batterySocPct, Double housePowerW, Double backupOutputW, Today today) {
|
||||
|
||||
/** One inverter; time is null and stale true when the gateway has no reading of it. */
|
||||
public record Source(String name, Instant time, boolean stale) {
|
||||
}
|
||||
|
||||
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
|
||||
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) {
|
||||
/** Day totals in kWh since local midnight; PV adds Deye and Fronius, house is the energy balance. */
|
||||
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double houseKwh) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/** What the forecast service says: the temperature now and a record per calendar day, past days included. */
|
||||
public record Forecast(Double temperature, Map<LocalDate, DailyForecast> days) {
|
||||
|
||||
/** Radiation in MJ/m² per day, sunshine in seconds; any value may be missing. */
|
||||
public record DailyForecast(Integer weatherCode, Double tMax, Double tMin, Double precipitationMm,
|
||||
Double radiationMj, Double sunshineSeconds) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
|
||||
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co,
|
||||
Double outdoorTemp, Double cwuTemp, Periods periods) {
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import java.time.Instant;
|
||||
* What the heat pump does now (F1). Without a reading from the gateway {@code time} and the values are
|
||||
* null; {@code stale} is true when the device is down or the reading is older than 2 × 300 s.
|
||||
*/
|
||||
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent, boolean heaterOn,
|
||||
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent,
|
||||
boolean alarm, Integer alarmCode, String alarmText, Pumps pumps, Temperatures temperatures,
|
||||
Targets targets, Setpoints setpoints) {
|
||||
Targets targets, Setpoints setpoints, Periods periods) {
|
||||
|
||||
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
|
||||
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/** Which period of the weekly program a circuit is in, read from its target (see {@link Periods}). */
|
||||
public enum Period {
|
||||
COMFORT, ECONOMY, UNKNOWN, OVERRIDE;
|
||||
|
||||
@JsonValue
|
||||
public String id() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
|
||||
/**
|
||||
* The period of CWU and room, from the target the controller works to now (verified on history,
|
||||
* docs/frisko/history-2026-10.md). Only the weekly program (register 4226 = 1) switches between
|
||||
* economy and comfort; any other program overrides both. {@code programText} names such a program.
|
||||
*/
|
||||
public record Periods(Period cwu, Period room, Integer program, String programText) {
|
||||
|
||||
public static final Periods UNKNOWN = new Periods(Period.UNKNOWN, Period.UNKNOWN, null, null);
|
||||
private static final int WEEKLY = 1;
|
||||
private static final double SAME_TARGET = 0.05;
|
||||
|
||||
public static Periods of(Integer program, Targets targets, Setpoints setpoints) {
|
||||
if (program == null) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
return new Periods(
|
||||
period(program, targets.cwu(), setpoints.cwuEconomy(), setpoints.cwuComfort()),
|
||||
period(program, targets.room(), setpoints.coEconomy(), setpoints.coComfort()),
|
||||
program, program == WEEKLY ? null : programText(program));
|
||||
}
|
||||
|
||||
private static Period period(int program, Double target, Integer economy, Integer comfort) {
|
||||
if (program != WEEKLY) {
|
||||
return Period.OVERRIDE;
|
||||
}
|
||||
if (target == null || economy == null || comfort == null || economy.equals(comfort)) {
|
||||
return Period.UNKNOWN;
|
||||
}
|
||||
if (Math.abs(target - comfort) < SAME_TARGET) {
|
||||
return Period.COMFORT;
|
||||
}
|
||||
return Math.abs(target - economy) < SAME_TARGET ? Period.ECONOMY : Period.UNKNOWN;
|
||||
}
|
||||
|
||||
private static String programText(int program) {
|
||||
return switch (program) {
|
||||
case 0 -> "Ferie";
|
||||
case 2 -> "Party";
|
||||
case 3 -> "Rozruch";
|
||||
case 4 -> "Stop";
|
||||
case 5 -> "Dezynfekcja";
|
||||
default -> "Program " + program;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The weekly program as the history shows it (F6): per weekday the comfort intervals of CWU and room.
|
||||
* Nothing is read from the controller's program registers. {@code weekdays} runs Monday (1) to Sunday (7).
|
||||
*/
|
||||
public record ProgramView(int days, List<Weekday> weekdays) {
|
||||
|
||||
public record Weekday(int day, Circuit cwu, Circuit room) {
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code observedDays}: days of this weekday with known periods; {@code agreeingDays}: how many of them
|
||||
* show {@code comfort} (within 5 minutes).
|
||||
*/
|
||||
public record Circuit(Confidence confidence, int observedDays, int agreeingDays, List<Interval> comfort) {
|
||||
}
|
||||
|
||||
/** Local times "HH:MM"; an interval that runs to midnight ends at "24:00". */
|
||||
public record Interval(String from, String to) {
|
||||
}
|
||||
|
||||
public enum Confidence {
|
||||
CONFIRMED, SEEN_ONCE, NO_DATA;
|
||||
|
||||
@JsonValue
|
||||
public String id() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The PV output per unit of radiation, found from past days instead of the panels' tilt and azimuth:
|
||||
* measured kWh over forecast MJ/m², summed over the days that have both.
|
||||
*/
|
||||
public final class PvCalibration {
|
||||
|
||||
static final int MIN_DAYS = 5;
|
||||
|
||||
private PvCalibration() {
|
||||
}
|
||||
|
||||
/** Null with fewer than {@value MIN_DAYS} days having both values or no radiation at all. */
|
||||
public static Double factor(Map<LocalDate, Double> measuredKwh, Map<LocalDate, Double> radiationMj) {
|
||||
double kwh = 0;
|
||||
double mj = 0;
|
||||
int days = 0;
|
||||
for (var day : measuredKwh.entrySet()) {
|
||||
Double radiation = radiationMj.get(day.getKey());
|
||||
if (radiation != null) {
|
||||
kwh += day.getValue();
|
||||
mj += radiation;
|
||||
days++;
|
||||
}
|
||||
}
|
||||
return days < MIN_DAYS || mj <= 0 ? null : kwh / mj;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Heat pump work per local day (F2). Hours are approximated from the polls, starts are counted
|
||||
* between polls, so short cycles are missed. A day without polls has null values.
|
||||
*/
|
||||
public record StatsView(String range, List<Day> days, Totals totals) {
|
||||
|
||||
public record Day(LocalDate date, Double compressorHours, Integer compressorStarts, Double cwuHours,
|
||||
Double coHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
|
||||
}
|
||||
|
||||
public record Totals(double compressorHours, int compressorStarts, double cwuHours, double coHours,
|
||||
Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package home.backend.domain;
|
||||
|
||||
/** WMO weather interpretation codes as Polish text. */
|
||||
public final class WeatherCodes {
|
||||
|
||||
private WeatherCodes() {
|
||||
}
|
||||
|
||||
public static String describe(Integer code) {
|
||||
if (code == null) {
|
||||
return "Brak danych";
|
||||
}
|
||||
return switch (code) {
|
||||
case 0 -> "Bezchmurnie";
|
||||
case 1 -> "Przeważnie słonecznie";
|
||||
case 2 -> "Częściowe zachmurzenie";
|
||||
case 3 -> "Pochmurno";
|
||||
case 45, 48 -> "Mgła";
|
||||
case 51, 53, 55 -> "Mżawka";
|
||||
case 56, 57 -> "Marznąca mżawka";
|
||||
case 61 -> "Słaby deszcz";
|
||||
case 63 -> "Deszcz";
|
||||
case 65 -> "Silny deszcz";
|
||||
case 66, 67 -> "Marznący deszcz";
|
||||
case 71 -> "Słaby śnieg";
|
||||
case 73 -> "Śnieg";
|
||||
case 75 -> "Intensywny śnieg";
|
||||
case 77 -> "Ziarna śnieżne";
|
||||
case 80, 81 -> "Przelotny deszcz";
|
||||
case 82 -> "Ulewa";
|
||||
case 85, 86 -> "Przelotny śnieg";
|
||||
case 95 -> "Burza";
|
||||
case 96, 99 -> "Burza z gradem";
|
||||
default -> "Brak danych";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Weather at the house: three days, the temperature now and the IMGW warnings in force or announced.
|
||||
* {@code pvFactor} (kWh per MJ/m² of forecast radiation) and every {@code pvForecastKwh} are null while
|
||||
* there are too few measured days to calibrate. {@code stale} means part of it is an older answer
|
||||
* because a source could not be reached.
|
||||
*/
|
||||
public record WeatherView(Instant time, Day today, Day tomorrow, Day dayAfter, Now now,
|
||||
List<Warning> warnings, Double pvFactor, boolean stale) {
|
||||
|
||||
public record Day(LocalDate date, Integer weatherCode, String description, Double tMax, Double tMin,
|
||||
Double precipitationMm, Double sunshineHours, Double pvForecastKwh) {
|
||||
}
|
||||
|
||||
public record Now(Double temperature) {
|
||||
}
|
||||
|
||||
/** {@code level} is IMGW's degree 1 to 3. */
|
||||
public record Warning(String event, int level, OffsetDateTime from, OffsetDateTime to, String text) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
package home.backend;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.adapters.auth.AuthSettings;
|
||||
import home.backend.adapters.auth.OidcSettings;
|
||||
import home.backend.adapters.auth.PasswordHash;
|
||||
import home.backend.adapters.auth.StubIssuer;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Sign-in end to end: local accounts and OIDC against a stub issuer, behind the real HTTP server. */
|
||||
class AuthApiTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final String SIGNING_KEY = "0123456789abcdef0123456789abcdef-test";
|
||||
private static final String PASSWORD = "correct horse";
|
||||
private static final String LOGIN = "{\"username\":\"tata\",\"password\":\"" + PASSWORD + "\"}";
|
||||
private static final String CALLBACK = "https://pompa.example/api/auth/oidc/callback";
|
||||
private static String hash;
|
||||
|
||||
private final HttpClient client = HttpClient.newHttpClient();
|
||||
private final List<ApiServer> servers = new ArrayList<>();
|
||||
private final List<HttpServer> stubs = new ArrayList<>();
|
||||
private final List<String> requestedBy = new ArrayList<>();
|
||||
private StubIssuer issuer;
|
||||
private ApiServer backend;
|
||||
private HttpServer gateway;
|
||||
@TempDir
|
||||
Path dir;
|
||||
|
||||
@BeforeAll
|
||||
static void hashOnce() {
|
||||
hash = PasswordHash.create(PASSWORD.toCharArray());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
servers.forEach(ApiServer::stop);
|
||||
stubs.forEach(s -> s.stop(0));
|
||||
if (issuer != null) {
|
||||
issuer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// --- set-up ---
|
||||
|
||||
private void launch(boolean local, boolean oidc, String role) throws IOException {
|
||||
issuer = new StubIssuer();
|
||||
launch(local ? users() : null, oidc ? oidcSettings(issuer.issuer(), role) : null, false);
|
||||
}
|
||||
|
||||
private Path users() throws IOException {
|
||||
return Files.writeString(dir.resolve("users.json"),
|
||||
"[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]");
|
||||
}
|
||||
|
||||
private static OidcSettings oidcSettings(URI issuer, String role) {
|
||||
return new OidcSettings(issuer, StubIssuer.CLIENT_ID, StubIssuer.CLIENT_KEY, URI.create(CALLBACK), role);
|
||||
}
|
||||
|
||||
private void launch(Path usersFile, OidcSettings oidc, boolean disabled) throws IOException {
|
||||
gateway = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
gateway.createContext("/commands", this::commands);
|
||||
gateway.start();
|
||||
stubs.add(gateway);
|
||||
URI dead = URI.create("http://127.0.0.1:1");
|
||||
var settings = new Settings(URI.create("http://127.0.0.1:" + gateway.getAddress().getPort()), dead, 0,
|
||||
Duration.ofSeconds(2), Duration.ofSeconds(2), "s3cret-token", null,
|
||||
new AuthSettings(usersFile, oidc, SIGNING_KEY, 30, disabled, 1));
|
||||
backend = Backend.assemble(settings, Clock.systemUTC());
|
||||
servers.add(backend);
|
||||
backend.start();
|
||||
}
|
||||
|
||||
private void commands(HttpExchange exchange) throws IOException {
|
||||
boolean post = exchange.getRequestMethod().equals("POST");
|
||||
if (post) {
|
||||
requestedBy.add(exchange.getRequestHeaders().getFirst("X-Requested-By"));
|
||||
}
|
||||
byte[] body = Files.readAllBytes(Path.of("../gateway/src/test/resources/commands/"
|
||||
+ (post ? "response-applied.json" : "setpoints.json")));
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
// --- requests ---
|
||||
|
||||
private HttpResponse<String> send(String method, String path, String body, String... headers) throws Exception {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
|
||||
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
|
||||
: HttpRequest.BodyPublishers.ofString(body));
|
||||
if (headers.length > 0) {
|
||||
request.headers(headers);
|
||||
}
|
||||
return client.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path, String... headers) throws Exception {
|
||||
return send("GET", path, null, headers);
|
||||
}
|
||||
|
||||
private HttpResponse<String> post(String path, String body, String... headers) throws Exception {
|
||||
var all = new ArrayList<>(List.of("Content-Type", "application/json", "X-Requested-With", "home-pump"));
|
||||
all.addAll(List.of(headers));
|
||||
return send("POST", path, body, all.toArray(String[]::new));
|
||||
}
|
||||
|
||||
private static String cookie(HttpResponse<?> response, String name) {
|
||||
return response.headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(name + "="))
|
||||
.findFirst().map(c -> c.split(";")[0]).orElse(null);
|
||||
}
|
||||
|
||||
private static String setCookie(HttpResponse<?> response, String name) {
|
||||
return response.headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(name + "="))
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private String signIn() throws Exception {
|
||||
return cookie(post("/api/auth/login", LOGIN), "hp_session");
|
||||
}
|
||||
|
||||
private static JsonNode json(HttpResponse<String> response) throws IOException {
|
||||
return JSON.readTree(response.body());
|
||||
}
|
||||
|
||||
private static JsonNode example(String name) throws IOException {
|
||||
return JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + name + ".json")));
|
||||
}
|
||||
|
||||
// --- what is open and what is not ---
|
||||
|
||||
@Test
|
||||
void configTellsWhichSignInsExist() throws Exception {
|
||||
launch(true, true, null);
|
||||
|
||||
assertEquals(example("auth-config"), json(get("/api/auth/config")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everythingElseNeedsASession() throws Exception {
|
||||
launch(true, true, null);
|
||||
|
||||
for (String path : List.of("/api/health", "/api/heat-pump", "/api/setpoints", "/api/audit", "/api/weather")) {
|
||||
var response = get(path);
|
||||
assertEquals(401, response.statusCode(), path);
|
||||
assertEquals("{\"error\":\"unauthenticated\"}", response.body(), path);
|
||||
}
|
||||
assertEquals(401, post("/api/setpoints", "{}").statusCode());
|
||||
assertEquals(401, get("/api/auth/me").statusCode());
|
||||
assertEquals(200, get("/api/live").statusCode());
|
||||
assertEquals("ok", get("/api/live").body());
|
||||
assertEquals(404, get("/api/unknown").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionCookieOpensTheApiAndNamesTheRequester() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
|
||||
assertEquals(200, get("/api/health", "Cookie", session).statusCode());
|
||||
assertEquals(200, post("/api/setpoints", "{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":2}",
|
||||
"Cookie", session, "Remote-User", "intruder").statusCode());
|
||||
assertEquals(List.of("tata"), requestedBy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void forgedSessionCookiesAreRefused() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
|
||||
assertEquals(401, get("/api/health", "Cookie", session + "x").statusCode());
|
||||
assertEquals(401, get("/api/health", "Cookie", "hp_session=" + session.split("\\.")[0].split("=")[1]).statusCode());
|
||||
assertEquals(401, get("/api/auth/me", "Cookie", "hp_session=eyJ1c2VyIjoiYWRtaW4ifQ.AAAA").statusCode());
|
||||
}
|
||||
|
||||
// --- local login ---
|
||||
|
||||
@Test
|
||||
void localLoginSetsAHardenedCookieAndMeAnswers() throws Exception {
|
||||
launch(true, false, null);
|
||||
|
||||
var login = post("/api/auth/login", LOGIN);
|
||||
|
||||
assertEquals(200, login.statusCode());
|
||||
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"local\"}"), json(login));
|
||||
String set = setCookie(login, "hp_session");
|
||||
for (String flag : new String[] {"HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=2592000"}) {
|
||||
assertTrue(set.contains(flag), flag);
|
||||
}
|
||||
var me = get("/api/auth/me", "Cookie", cookie(login, "hp_session"));
|
||||
assertEquals(200, me.statusCode());
|
||||
assertEquals(example("auth-me"), json(me));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongPasswordAndUnknownUserLookAlike() throws Exception {
|
||||
launch(true, false, null);
|
||||
|
||||
for (String body : List.of("{\"username\":\"tata\",\"password\":\"nope\"}",
|
||||
"{\"username\":\"ghost\",\"password\":\"" + PASSWORD + "\"}")) {
|
||||
var response = post("/api/auth/login", body);
|
||||
assertEquals(401, response.statusCode());
|
||||
assertEquals("{\"error\":\"invalid\"}", response.body());
|
||||
assertEquals(null, setCookie(response, "hp_session"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fiveFailuresThrottleTheUserAndTheAddressEvenForTheRightPassword() throws Exception {
|
||||
launch(true, false, null);
|
||||
String wrong = "{\"username\":\"tata\",\"password\":\"nope\"}";
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertEquals(401, post("/api/auth/login", wrong, "X-Forwarded-For", "1.1.1." + i + ", 7.7.7.7").statusCode());
|
||||
}
|
||||
|
||||
var user = post("/api/auth/login", LOGIN, "X-Forwarded-For", "8.8.8.8");
|
||||
var address = post("/api/auth/login", "{\"username\":\"ghost\",\"password\":\"x\"}",
|
||||
"X-Forwarded-For", "2.2.2.2, 7.7.7.7");
|
||||
var other = post("/api/auth/login", "{\"username\":\"ghost\",\"password\":\"x\"}",
|
||||
"X-Forwarded-For", "2.2.2.2, 8.8.8.8");
|
||||
|
||||
assertEquals(429, user.statusCode());
|
||||
assertEquals("throttled", json(user).get("error").asText());
|
||||
int wait = json(user).get("retryAfterSeconds").asInt();
|
||||
assertTrue(wait > 14 * 60 && wait <= 15 * 60 + 1, "retry after " + wait);
|
||||
assertEquals(String.valueOf(wait), user.headers().firstValue("Retry-After").orElseThrow());
|
||||
assertEquals(429, address.statusCode());
|
||||
assertEquals(401, other.statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginAndLogoutKeepTheWriteGuards() throws Exception {
|
||||
launch(true, false, null);
|
||||
|
||||
assertEquals(403, post("/api/auth/login", LOGIN, "Origin", "https://evil.example").statusCode());
|
||||
assertEquals(403, send("POST", "/api/auth/login", LOGIN, "Content-Type", "application/json").statusCode());
|
||||
assertEquals(415, send("POST", "/api/auth/login", LOGIN, "X-Requested-With", "home-pump",
|
||||
"Content-Type", "text/plain").statusCode());
|
||||
assertEquals(400, post("/api/auth/login", "{}").statusCode());
|
||||
assertEquals(400, post("/api/auth/login", "not json").statusCode());
|
||||
assertEquals(403, send("POST", "/api/auth/logout", "{}", "Content-Type", "application/json").statusCode());
|
||||
assertEquals(405, get("/api/auth/login").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionOfARemovedAccountStopsWorking() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
assertEquals(200, get("/api/health", "Cookie", session).statusCode());
|
||||
|
||||
Files.writeString(dir.resolve("users.json"), "[{\"username\":\"mama\",\"passwordHash\":\"" + hash + "\"}]");
|
||||
launch(dir.resolve("users.json"), null, false);
|
||||
|
||||
assertEquals(401, get("/api/health", "Cookie", session).statusCode());
|
||||
assertEquals(401, get("/api/auth/me", "Cookie", session).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parallelWrongPasswordsAreEvaluatedAtMostFiveTimes() throws Exception {
|
||||
launch(true, false, null);
|
||||
var pool = java.util.concurrent.Executors.newFixedThreadPool(40);
|
||||
var futures = new ArrayList<java.util.concurrent.Future<Integer>>();
|
||||
for (int i = 0; i < 40; i++) {
|
||||
futures.add(pool.submit(() -> post("/api/auth/login", "{\"username\":\"tata\",\"password\":\"nope\"}")
|
||||
.statusCode()));
|
||||
}
|
||||
int evaluated = 0;
|
||||
for (var future : futures) {
|
||||
if (future.get() == 401) {
|
||||
evaluated++;
|
||||
}
|
||||
}
|
||||
pool.shutdown();
|
||||
|
||||
assertTrue(evaluated >= 1 && evaluated <= 5, "evaluated " + evaluated);
|
||||
}
|
||||
|
||||
// --- first account and account management ---
|
||||
|
||||
private static final String NEW_PASSWORD = "a brand new password";
|
||||
|
||||
private static String account(String name, String password) {
|
||||
return "{\"username\":\"" + name + "\",\"password\":\"" + password + "\"}";
|
||||
}
|
||||
|
||||
private void launchInSetupMode() throws IOException {
|
||||
launch(dir.resolve("auth-users.json"), null, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupCreatesTheFirstAccountOnceAndSignsItIn() throws Exception {
|
||||
launchInSetupMode();
|
||||
assertEquals(JSON.readTree("{\"local\":true,\"oidc\":false,\"setup\":true}"), json(get("/api/auth/config")));
|
||||
|
||||
var created = post("/api/auth/setup", account("tata", NEW_PASSWORD));
|
||||
|
||||
assertEquals(200, created.statusCode());
|
||||
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"local\"}"), json(created));
|
||||
assertEquals(200, get("/api/health", "Cookie", cookie(created, "hp_session")).statusCode());
|
||||
assertEquals(JSON.readTree("{\"local\":true,\"oidc\":false,\"setup\":false}"), json(get("/api/auth/config")));
|
||||
var second = post("/api/auth/setup", account("mama", NEW_PASSWORD));
|
||||
assertEquals(409, second.statusCode());
|
||||
assertEquals("{\"error\":\"setup_done\"}", second.body());
|
||||
assertEquals(200, post("/api/auth/login", account("tata", NEW_PASSWORD)).statusCode());
|
||||
assertEquals(401, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupWithAWeakPasswordOrOddNameIsRefusedAndCreatesNothing() throws Exception {
|
||||
launchInSetupMode();
|
||||
|
||||
var weak = post("/api/auth/setup", account("tata", "short"));
|
||||
var odd = post("/api/auth/setup", account("a b", NEW_PASSWORD));
|
||||
|
||||
assertEquals(400, weak.statusCode());
|
||||
assertEquals("{\"error\":\"weak_password\"}", weak.body());
|
||||
assertEquals(400, odd.statusCode());
|
||||
assertEquals(true, json(get("/api/auth/config")).get("setup").asBoolean());
|
||||
assertFalse(Files.exists(dir.resolve("auth-users.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupKeepsTheWriteGuards() throws Exception {
|
||||
launchInSetupMode();
|
||||
|
||||
assertEquals(403, post("/api/auth/setup", account("tata", NEW_PASSWORD), "Origin", "https://evil.example")
|
||||
.statusCode());
|
||||
assertEquals(403, send("POST", "/api/auth/setup", account("tata", NEW_PASSWORD), "Content-Type",
|
||||
"application/json").statusCode());
|
||||
assertEquals(415, send("POST", "/api/auth/setup", account("tata", NEW_PASSWORD), "X-Requested-With",
|
||||
"home-pump", "Content-Type", "text/plain").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupIsNotFoundWithoutLocalAccounts() throws Exception {
|
||||
launch(false, true, null);
|
||||
|
||||
assertEquals(404, post("/api/auth/setup", account("tata", NEW_PASSWORD)).statusCode());
|
||||
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":true,\"setup\":false}"), json(get("/api/auth/config")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoParallelSetupsLeaveExactlyOneWinner() throws Exception {
|
||||
launchInSetupMode();
|
||||
var pool = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
var a = pool.submit(() -> post("/api/auth/setup", account("tata", NEW_PASSWORD)).statusCode());
|
||||
var b = pool.submit(() -> post("/api/auth/setup", account("mama", NEW_PASSWORD)).statusCode());
|
||||
var codes = new java.util.TreeSet<>(List.of(a.get(), b.get()));
|
||||
pool.shutdown();
|
||||
|
||||
assertEquals(new java.util.TreeSet<>(List.of(200, 409)), codes);
|
||||
assertEquals(1, JSON.readTree(Files.readString(dir.resolve("auth-users.json"))).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountManagementNeedsASession() throws Exception {
|
||||
launch(true, false, null);
|
||||
|
||||
assertEquals(401, get("/api/auth/users").statusCode());
|
||||
assertEquals(401, post("/api/auth/users", account("mama", NEW_PASSWORD)).statusCode());
|
||||
assertEquals(401, post("/api/auth/password", "{\"current\":\"x\",\"new\":\"" + NEW_PASSWORD + "\"}")
|
||||
.statusCode());
|
||||
assertEquals(401, post("/api/auth/users/tata/delete", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usersCanBeListedAddedAndRemovedButNotTheLastOne() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
|
||||
assertEquals(JSON.readTree("[{\"username\":\"tata\"}]"), json(get("/api/auth/users", "Cookie", session)));
|
||||
assertEquals(200, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
|
||||
assertEquals(409, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
|
||||
assertEquals(400, post("/api/auth/users", account("zosia", "short"), "Cookie", session).statusCode());
|
||||
assertEquals(2, json(get("/api/auth/users", "Cookie", session)).size());
|
||||
assertEquals(200, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
|
||||
|
||||
assertEquals(204, post("/api/auth/users/mama/delete", "{}", "Cookie", session).statusCode());
|
||||
assertEquals(404, post("/api/auth/users/mama/delete", "{}", "Cookie", session).statusCode());
|
||||
var last = post("/api/auth/users/tata/delete", "{}", "Cookie", session);
|
||||
assertEquals(409, last.statusCode());
|
||||
assertEquals("{\"error\":\"last_user\"}", last.body());
|
||||
assertEquals(403, send("POST", "/api/auth/users/mama/delete", "{}", "Cookie", session,
|
||||
"Content-Type", "application/json").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOidcSessionMayManageAccountsButNotChangeAPassword() throws Exception {
|
||||
launch(true, true, null);
|
||||
Started started = start();
|
||||
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
|
||||
String session = cookie(callbackWithCode(started), "hp_session");
|
||||
|
||||
assertEquals(200, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
|
||||
assertEquals(403, post("/api/auth/password", "{\"current\":\"x\",\"new\":\"" + NEW_PASSWORD + "\"}",
|
||||
"Cookie", session).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void removedAccountLosesItsSessionAtOnceAndRemovingYourselfSignsYouOut() throws Exception {
|
||||
launch(true, false, null);
|
||||
String tata = signIn();
|
||||
post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", tata);
|
||||
String mama = cookie(post("/api/auth/login", account("mama", NEW_PASSWORD)), "hp_session");
|
||||
assertEquals(200, get("/api/health", "Cookie", mama).statusCode());
|
||||
|
||||
assertEquals(204, post("/api/auth/users/mama/delete", "{}", "Cookie", tata).statusCode());
|
||||
assertEquals(401, get("/api/health", "Cookie", mama).statusCode());
|
||||
|
||||
post("/api/auth/users", account("zosia", NEW_PASSWORD), "Cookie", tata);
|
||||
var self = post("/api/auth/users/tata/delete", "{}", "Cookie", tata);
|
||||
assertEquals(204, self.statusCode());
|
||||
assertTrue(setCookie(self, "hp_session").startsWith("hp_session=; Max-Age=0"));
|
||||
assertEquals(401, get("/api/health", "Cookie", tata).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownPasswordChangeNeedsTheCurrentOneAndCountsFailuresInTheThrottle() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
String wrong = "{\"current\":\"nope\",\"new\":\"" + NEW_PASSWORD + "\"}";
|
||||
|
||||
assertEquals(400, post("/api/auth/password", "{\"current\":\"" + PASSWORD + "\",\"new\":\"short\"}",
|
||||
"Cookie", session).statusCode());
|
||||
var rejected = post("/api/auth/password", wrong, "Cookie", session);
|
||||
assertEquals(401, rejected.statusCode());
|
||||
assertEquals("{\"error\":\"invalid\"}", rejected.body());
|
||||
assertEquals(204, post("/api/auth/password", "{\"current\":\"" + PASSWORD + "\",\"new\":\""
|
||||
+ NEW_PASSWORD + "\"}", "Cookie", session).statusCode());
|
||||
|
||||
assertEquals(401, post("/api/auth/login", LOGIN).statusCode());
|
||||
assertEquals(200, post("/api/auth/login", account("tata", NEW_PASSWORD)).statusCode());
|
||||
for (int i = 0; i < 5; i++) {
|
||||
post("/api/auth/password", wrong, "Cookie", session);
|
||||
}
|
||||
assertEquals(429, post("/api/auth/password", wrong, "Cookie", session).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void changesSurviveARestartBecauseTheFileIsRewritten() throws Exception {
|
||||
launch(true, false, null);
|
||||
String session = signIn();
|
||||
post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session);
|
||||
|
||||
launch(dir.resolve("users.json"), null, false);
|
||||
|
||||
assertEquals(200, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutClearsTheCookie() throws Exception {
|
||||
launch(true, false, null);
|
||||
|
||||
var response = post("/api/auth/logout", "{}", "Cookie", signIn());
|
||||
|
||||
assertEquals(204, response.statusCode());
|
||||
assertTrue(setCookie(response, "hp_session").startsWith("hp_session=; Max-Age=0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginIsNotFoundWithoutLocalAccounts() throws Exception {
|
||||
launch(false, true, null);
|
||||
|
||||
assertEquals(404, post("/api/auth/login", LOGIN).statusCode());
|
||||
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":true,\"setup\":false}"), json(get("/api/auth/config")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledAuthenticationOpensTheApiAsPhone() throws Exception {
|
||||
launch(null, null, true);
|
||||
|
||||
assertEquals(200, get("/api/health").statusCode());
|
||||
assertEquals(JSON.readTree("{\"user\":\"phone\",\"method\":\"none\"}"), json(get("/api/auth/me")));
|
||||
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":false,\"setup\":false}"), json(get("/api/auth/config")));
|
||||
assertEquals(204, post("/api/auth/logout", "{}").statusCode());
|
||||
}
|
||||
|
||||
// --- OIDC ---
|
||||
|
||||
private record Started(Map<String, String> query, String transaction) {
|
||||
}
|
||||
|
||||
private Started start() throws Exception {
|
||||
var response = get("/api/auth/oidc/start");
|
||||
assertEquals(302, response.statusCode());
|
||||
URI location = URI.create(response.headers().firstValue("Location").orElseThrow());
|
||||
assertEquals(issuer.issuer() + "/protocol/openid-connect/auth", location.toString().split("\\?")[0]);
|
||||
String set = setCookie(response, "hp_oidc");
|
||||
assertTrue(set.contains("HttpOnly") && set.contains("Secure") && set.contains("SameSite=Lax")
|
||||
&& set.contains("Max-Age=600"), set);
|
||||
return new Started(query(location.getRawQuery()), cookie(response, "hp_oidc"));
|
||||
}
|
||||
|
||||
private static Map<String, String> query(String raw) {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
for (String pair : raw.split("&")) {
|
||||
String[] kv = pair.split("=", 2);
|
||||
query.put(kv[0], URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
private HttpResponse<String> callback(Started started, String queryString) throws Exception {
|
||||
return get("/api/auth/oidc/callback?" + queryString, "Cookie", started.transaction());
|
||||
}
|
||||
|
||||
private HttpResponse<String> callbackWithCode(Started started) throws Exception {
|
||||
return callback(started, "code=abc&state=" + started.query().get("state"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oidcLoginSendsTheRightRequestAndSignsTheUserIn() throws Exception {
|
||||
launch(false, true, "home-pump");
|
||||
Started started = start();
|
||||
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
|
||||
|
||||
var response = callbackWithCode(started);
|
||||
|
||||
assertEquals(302, response.statusCode());
|
||||
assertEquals("/", response.headers().firstValue("Location").orElseThrow());
|
||||
assertTrue(setCookie(response, "hp_oidc").startsWith("hp_oidc=; Max-Age=0"));
|
||||
var me = get("/api/auth/me", "Cookie", cookie(response, "hp_session"));
|
||||
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"oidc\"}"), json(me));
|
||||
|
||||
var authorize = started.query();
|
||||
assertEquals("code", authorize.get("response_type"));
|
||||
assertEquals(StubIssuer.CLIENT_ID, authorize.get("client_id"));
|
||||
assertEquals(CALLBACK, authorize.get("redirect_uri"));
|
||||
assertEquals("S256", authorize.get("code_challenge_method"));
|
||||
assertTrue(authorize.get("scope").contains("openid"));
|
||||
var token = issuer.tokenRequests.getFirst();
|
||||
assertEquals("Basic " + Base64.getEncoder().encodeToString(
|
||||
(StubIssuer.CLIENT_ID + ":" + StubIssuer.CLIENT_KEY).getBytes(StandardCharsets.UTF_8)),
|
||||
token.authorization());
|
||||
assertEquals("authorization_code", token.form().get("grant_type"));
|
||||
assertEquals("abc", token.form().get("code"));
|
||||
assertEquals(CALLBACK, token.form().get("redirect_uri"));
|
||||
assertEquals(authorize.get("code_challenge"), Base64.getUrlEncoder().withoutPadding().encodeToString(
|
||||
MessageDigest.getInstance("SHA-256").digest(
|
||||
token.form().get("code_verifier").getBytes(StandardCharsets.US_ASCII))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oidcTokensThatDoNotVerifyEndInOidcFailed() throws Exception {
|
||||
launch(false, true, null);
|
||||
long now = Instant.now().getEpochSecond();
|
||||
List<Function<String, String>> bad = List.of(
|
||||
nonce -> StubIssuer.sign("RS256", "k1", Map.of("iss", issuer.issuer().toString(),
|
||||
"aud", StubIssuer.CLIENT_ID, "nonce", nonce, "iat", now, "exp", now + 300,
|
||||
"preferred_username", "tata"), StubIssuer.newKeys().getPrivate()),
|
||||
nonce -> issuer.idToken(nonce, c -> c.put("aud", "other")),
|
||||
nonce -> issuer.idToken(nonce, c -> c.put("exp", now - 600)),
|
||||
nonce -> issuer.idToken("another-nonce", c -> { }));
|
||||
for (var token : bad) {
|
||||
Started started = start();
|
||||
issuer.idTokenToServe = token.apply(started.query().get("nonce"));
|
||||
|
||||
var response = callbackWithCode(started);
|
||||
|
||||
assertEquals("/#login?error=oidc_failed", response.headers().firstValue("Location").orElseThrow());
|
||||
assertEquals(null, cookie(response, "hp_session"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongStateOrMissingLoginIsRefusedWithoutAskingTheIssuer() throws Exception {
|
||||
launch(false, true, null);
|
||||
Started started = start();
|
||||
|
||||
var wrongState = callback(started, "code=abc&state=forged");
|
||||
var noCookie = get("/api/auth/oidc/callback?code=abc&state=" + started.query().get("state"));
|
||||
var noParameters = callback(started, "");
|
||||
|
||||
for (var response : List.of(wrongState, noCookie, noParameters)) {
|
||||
assertEquals("/#login?error=oidc_failed", response.headers().firstValue("Location").orElseThrow());
|
||||
}
|
||||
assertTrue(issuer.tokenRequests.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingRoleIsDenied() throws Exception {
|
||||
launch(false, true, "admin");
|
||||
Started started = start();
|
||||
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
|
||||
|
||||
var response = callbackWithCode(started);
|
||||
|
||||
assertEquals("/#login?error=oidc_denied", response.headers().firstValue("Location").orElseThrow());
|
||||
assertEquals(null, cookie(response, "hp_session"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusedByTheUserIsDenied() throws Exception {
|
||||
launch(false, true, null);
|
||||
Started started = start();
|
||||
|
||||
var response = callback(started, "error=access_denied&state=" + started.query().get("state"));
|
||||
|
||||
assertEquals("/#login?error=oidc_denied", response.headers().firstValue("Location").orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenEndpointRefusalIsOidcFailed() throws Exception {
|
||||
launch(false, true, null);
|
||||
Started started = start();
|
||||
issuer.tokenStatus = 400;
|
||||
|
||||
assertEquals("/#login?error=oidc_failed",
|
||||
callbackWithCode(started).headers().firstValue("Location").orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keycloakDownBeforeTheStartRedirectsBackWhileLocalLoginWorks() throws Exception {
|
||||
issuer = new StubIssuer();
|
||||
URI gone = issuer.issuer();
|
||||
issuer.stop();
|
||||
launch(users(), oidcSettings(gone, null), false);
|
||||
|
||||
var start = get("/api/auth/oidc/start");
|
||||
|
||||
assertEquals(302, start.statusCode());
|
||||
assertEquals("/#login?error=oidc_unavailable", start.headers().firstValue("Location").orElseThrow());
|
||||
assertEquals(null, cookie(start, "hp_oidc"));
|
||||
assertEquals(200, get("/api/auth/config").statusCode());
|
||||
assertTrue(signIn() != null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keycloakDownDuringTheCallbackIsUnavailable() throws Exception {
|
||||
launch(true, true, null);
|
||||
Started started = start();
|
||||
issuer.stop();
|
||||
|
||||
var response = callbackWithCode(started);
|
||||
|
||||
assertEquals("/#login?error=oidc_unavailable", response.headers().firstValue("Location").orElseThrow());
|
||||
assertEquals(200, post("/api/auth/login", LOGIN).statusCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package home.backend;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** The environment rules that keep the backend from starting open by accident. */
|
||||
class AuthSettingsTest {
|
||||
|
||||
private static final String SIGNING_KEY = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
private static Map<String, String> env(String... extra) {
|
||||
Map<String, String> env = new HashMap<>(Map.of("GATEWAY_URL", "http://10.0.0.2:8080",
|
||||
"INFLUX_URL", "https://influx.example"));
|
||||
for (int i = 0; i < extra.length; i += 2) {
|
||||
env.put(extra[i], extra[i + 1]);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
private static String refusal(String... extra) {
|
||||
return assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(env(extra))).getMessage();
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnySignInTheBackendRefusesToStart() {
|
||||
assertTrue(refusal().contains("AUTH_DISABLED=true"));
|
||||
assertTrue(refusal("AUTH_DISABLED", "false").contains("no sign-in configured"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authDisabledIsAnExplicitChoice() {
|
||||
var auth = Settings.fromEnvironment(env("AUTH_DISABLED", "true")).auth();
|
||||
|
||||
assertTrue(auth.disabled());
|
||||
assertNull(auth.oidc());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledContradictsConfiguredSignIn() {
|
||||
assertTrue(refusal("AUTH_DISABLED", "true", "USERS_FILE", "/data/users.json").contains("contradicts"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void configuredSignInNeedsALongSessionSecret() {
|
||||
assertTrue(refusal("USERS_FILE", "/data/users.json").contains("SESSION_SECRET"));
|
||||
assertTrue(refusal("USERS_FILE", "/data/users.json", "SESSION_SECRET", "short").contains("32 bytes"));
|
||||
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "OIDC_CLIENT_ID", "x",
|
||||
"OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb").contains("SESSION_SECRET"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oidcWithoutARequiredRoleWarns() {
|
||||
var open = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
|
||||
"OIDC_CLIENT_ID", "x", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb",
|
||||
"SESSION_SECRET", SIGNING_KEY)).auth();
|
||||
var restricted = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
|
||||
"OIDC_CLIENT_ID", "x", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb",
|
||||
"OIDC_REQUIRED_ROLE", "family", "SESSION_SECRET", SIGNING_KEY)).auth();
|
||||
|
||||
assertTrue(open.warnings().getFirst().contains("every user of the Keycloak realm"));
|
||||
assertTrue(restricted.warnings().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oidcNeedsItsClientSettings() {
|
||||
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "SESSION_SECRET", SIGNING_KEY)
|
||||
.contains("OIDC_CLIENT_ID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void localAndOidcAreConfiguredIndependently() {
|
||||
var local = Settings.fromEnvironment(env("USERS_FILE", "/data/users.json", "SESSION_SECRET", SIGNING_KEY)).auth();
|
||||
var oidc = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
|
||||
"OIDC_CLIENT_ID", "home-pump", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI",
|
||||
"https://pompa.example/api/auth/oidc/callback", "OIDC_REQUIRED_ROLE", "family",
|
||||
"SESSION_SECRET", SIGNING_KEY, "SESSION_DAYS", "7", "TRUSTED_PROXY_HOPS", "2")).auth();
|
||||
|
||||
assertEquals("/data/users.json", local.usersFile().toString());
|
||||
assertNull(local.oidc());
|
||||
assertEquals(30, local.sessionDays());
|
||||
assertEquals(1, local.proxyHops());
|
||||
assertNull(oidc.usersFile());
|
||||
assertEquals("family", oidc.oidc().requiredRole());
|
||||
assertEquals(7, oidc.sessionDays());
|
||||
assertEquals(2, oidc.proxyHops());
|
||||
assertFalse(oidc.disabled());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.adapters.auth.AuthSettings;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -43,6 +44,8 @@ class BackendTest {
|
||||
/** Just after the readings in the snapshot fixture, so they count as fresh. */
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC);
|
||||
|
||||
private static final AuthSettings OPEN = new AuthSettings(null, null, null, 30, true, 1);
|
||||
|
||||
private final HttpClient client = HttpClient.newHttpClient();
|
||||
private HttpServer gateway;
|
||||
private HttpServer influx;
|
||||
@@ -56,7 +59,7 @@ class BackendTest {
|
||||
gateway.createContext("/commands", this::commands);
|
||||
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
|
||||
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
|
||||
"s3cret-token", null);
|
||||
"s3cret-token", null, OPEN);
|
||||
backend = Backend.assemble(settings, CLOCK);
|
||||
backend.start();
|
||||
}
|
||||
@@ -83,7 +86,7 @@ class BackendTest {
|
||||
|
||||
private HttpResponse<String> post(String path, String body) throws Exception {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
|
||||
.header("Remote-User", "anna").header("Content-Type", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Requested-With", "home-pump")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
@@ -199,7 +202,7 @@ class BackendTest {
|
||||
JSON.readTree(response.body()));
|
||||
assertEquals(1, commandsSeen.size());
|
||||
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
|
||||
assertEquals("anna", commandsSeen.getFirst().requestedBy());
|
||||
assertEquals("phone", commandsSeen.getFirst().requestedBy());
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
|
||||
JSON.readTree(commandsSeen.getFirst().body()));
|
||||
}
|
||||
@@ -209,7 +212,7 @@ class BackendTest {
|
||||
Path file = Files.writeString(dir.resolve("presets.json"),
|
||||
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
|
||||
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(2), "s3cret-token", file), CLOCK);
|
||||
Duration.ofSeconds(2), "s3cret-token", file, OPEN), CLOCK);
|
||||
custom.start();
|
||||
try {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
|
||||
@@ -229,7 +232,7 @@ class BackendTest {
|
||||
@Test
|
||||
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
|
||||
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(2), null, null), CLOCK);
|
||||
Duration.ofSeconds(2), null, null, OPEN), CLOCK);
|
||||
noToken.start();
|
||||
try {
|
||||
var base = "http://127.0.0.1:" + noToken.port();
|
||||
@@ -256,7 +259,7 @@ class BackendTest {
|
||||
@Test
|
||||
void urlsMayEndWithASingleSlash() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/"));
|
||||
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/", "AUTH_DISABLED", "true"));
|
||||
|
||||
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
|
||||
}
|
||||
@@ -264,7 +267,7 @@ class BackendTest {
|
||||
@Test
|
||||
void settingsHaveDefaults() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example"));
|
||||
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example", "AUTH_DISABLED", "true"));
|
||||
|
||||
assertEquals(8080, settings.port());
|
||||
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
|
||||
@@ -273,7 +276,8 @@ class BackendTest {
|
||||
|
||||
@Test
|
||||
void tokenAndPresetsFileAreOptional() {
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example",
|
||||
"AUTH_DISABLED", "true");
|
||||
var none = Settings.fromEnvironment(urls);
|
||||
var env = new HashMap<>(urls);
|
||||
env.put("GATEWAY_TOKEN", " s3cret-token ");
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
|
||||
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class IdTokenVerifierTest {
|
||||
|
||||
private static StubIssuer issuer;
|
||||
|
||||
@BeforeAll
|
||||
static void start() throws IOException {
|
||||
issuer = new StubIssuer();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stop() {
|
||||
issuer.stop();
|
||||
}
|
||||
|
||||
private IdTokenVerifier verifier(String role) {
|
||||
return new IdTokenVerifier(new OidcSettings(issuer.issuer(), StubIssuer.CLIENT_ID, "x",
|
||||
URI.create("https://pompa.example/api/auth/oidc/callback"), role), Clock.systemUTC());
|
||||
}
|
||||
|
||||
private String verify(String token, String role) throws OidcException {
|
||||
return verifier(role).verify(token, "n1", kid -> kid.equals("k1") ? issuer.publicKey() : null);
|
||||
}
|
||||
|
||||
private OidcException failure(String token, String role) {
|
||||
return assertThrows(OidcException.class, () -> verify(token, role));
|
||||
}
|
||||
|
||||
private String token(Consumer<Map<String, Object>> change) {
|
||||
return issuer.idToken("n1", change);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validTokenGivesTheUserName() throws Exception {
|
||||
assertEquals("tata", verify(token(c -> { }), "home-pump"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userNameFallsBackToEmailThenSubject() throws Exception {
|
||||
assertEquals("tata@example.pl", verify(token(c -> {
|
||||
c.remove("preferred_username");
|
||||
c.put("email", "tata@example.pl");
|
||||
}), null));
|
||||
assertEquals("7f1c", verify(token(c -> c.remove("preferred_username")), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void signatureOfAnotherKeyIsRefused() {
|
||||
var forged = StubIssuer.sign("RS256", "k1", Map.of("iss", issuer.issuer().toString(), "aud", "home-pump"),
|
||||
StubIssuer.newKeys().getPrivate());
|
||||
|
||||
assertEquals(FAILED, failure(forged, null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamperedPayloadIsRefused() {
|
||||
String[] parts = token(c -> { }).split("\\.");
|
||||
String other = token(c -> c.put("preferred_username", "admin")).split("\\.")[1];
|
||||
|
||||
assertEquals(FAILED, failure(parts[0] + "." + other + "." + parts[2], null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherAlgorithmsAreRefused() {
|
||||
String none = "eyJhbGciOiJub25lIn0." + token(c -> { }).split("\\.")[1] + ".";
|
||||
|
||||
assertEquals(FAILED, failure(none, null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenForAnotherClientIsRefused() {
|
||||
assertEquals(FAILED, failure(token(c -> c.put("aud", "other-app")), null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void severalAudiencesNeedOurClientAsAuthorizedParty() throws Exception {
|
||||
assertEquals(FAILED, failure(token(c -> c.put("aud", List.of("home-pump", "account"))), null).reason());
|
||||
assertEquals("tata", verify(token(c -> {
|
||||
c.put("aud", List.of("home-pump", "account"));
|
||||
c.put("azp", "home-pump");
|
||||
}), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongIssuerIsRefused() {
|
||||
assertEquals(FAILED, failure(token(c -> c.put("iss", "https://evil.example/realms/home")), null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTokenIsRefusedButASmallSkewIsAllowed() throws Exception {
|
||||
long now = java.time.Instant.now().getEpochSecond();
|
||||
|
||||
assertEquals(FAILED, failure(token(c -> c.put("exp", now - 120)), null).reason());
|
||||
assertEquals("tata", verify(token(c -> c.put("exp", now - 10)), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenIssuedInTheFutureIsRefused() {
|
||||
long now = java.time.Instant.now().getEpochSecond();
|
||||
|
||||
assertEquals(FAILED, failure(token(c -> c.put("iat", now + 600)), null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongNonceIsRefused() {
|
||||
assertEquals(FAILED, failure(issuer.idToken("another", c -> { }), null).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiredRoleMayBeARealmRoleOrAClientRole() throws Exception {
|
||||
assertEquals("tata", verify(token(c -> { }), "home-pump"));
|
||||
assertEquals("tata", verify(token(c -> {
|
||||
c.remove("realm_access");
|
||||
c.put("resource_access", Map.of("home-pump", Map.of("roles", List.of("family"))));
|
||||
}), "family"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingRoleIsDenied() {
|
||||
assertEquals(DENIED, failure(token(c -> { }), "admin").reason());
|
||||
assertEquals(DENIED, failure(token(c -> c.put("resource_access",
|
||||
Map.of("account", Map.of("roles", List.of("admin"))))), "admin").reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownKeyIsRefused() {
|
||||
var verifier = verifier(null);
|
||||
|
||||
assertEquals(FAILED, assertThrows(OidcException.class,
|
||||
() -> verifier.verify(token(c -> { }), "n1", kid -> {
|
||||
throw new OidcException(FAILED, "no signing key " + kid);
|
||||
})).reason());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Hashing, the accounts file and the hash-password command. */
|
||||
class PasswordTest {
|
||||
|
||||
private static String hash;
|
||||
|
||||
@BeforeAll
|
||||
static void hashOnce() {
|
||||
hash = PasswordHash.create("correct horse".toCharArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hashIsSelfDescribingSaltedAndChecked() {
|
||||
String[] parts = hash.split("\\$");
|
||||
|
||||
assertEquals("pbkdf2-sha256", parts[0]);
|
||||
assertTrue(Integer.parseInt(parts[1]) >= 310_000);
|
||||
assertTrue(PasswordHash.matches(hash, "correct horse".toCharArray()));
|
||||
assertFalse(PasswordHash.matches(hash, "wrong horse".toCharArray()));
|
||||
assertNotEquals(hash, PasswordHash.create("correct horse".toCharArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void weakOrMalformedHashesAreRefused() {
|
||||
for (String bad : new String[] {"", "plain", "pbkdf2-sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA",
|
||||
"pbkdf2-sha256$x$y$z", "md5$600000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA"}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> PasswordHash.requireValid(bad), bad);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void usersFileHoldsAccounts(@TempDir Path dir) throws IOException {
|
||||
UserStore users = UserStore.load(file(dir, "[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]"));
|
||||
|
||||
assertTrue(users.authenticate("tata", "correct horse".toCharArray()));
|
||||
assertFalse(users.authenticate("tata", "nope".toCharArray()));
|
||||
assertFalse(users.authenticate("ghost", "correct horse".toCharArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void badUsersFilesStopTheStart(@TempDir Path dir) throws IOException {
|
||||
String account = "{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}";
|
||||
for (String bad : new String[] {"not json", "[" + account + "," + account + "]",
|
||||
"[{\"username\":\"a b\",\"passwordHash\":\"" + hash + "\"}]",
|
||||
"[{\"username\":\"tata\",\"passwordHash\":\"plain\"}]"}) {
|
||||
Path file = file(dir, bad);
|
||||
assertThrows(IllegalArgumentException.class, () -> UserStore.load(file), bad);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrEmptyFileIsAnEmptyStoreForSetup(@TempDir Path dir) throws IOException {
|
||||
assertTrue(UserStore.load(dir.resolve("missing.json")).isEmpty());
|
||||
assertTrue(UserStore.load(file(dir, "[]")).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void changesAreWrittenAtomicallyPrivatelyAndReadBack(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("users.json");
|
||||
UserStore users = UserStore.load(file);
|
||||
|
||||
assertTrue(users.addFirst("tata", hash));
|
||||
assertFalse(users.addFirst("mama", hash));
|
||||
assertTrue(users.add("mama", hash));
|
||||
assertFalse(users.add("mama", hash));
|
||||
assertTrue(users.changePassword("mama", PasswordHash.create("another password".toCharArray())));
|
||||
assertFalse(users.changePassword("ghost", hash));
|
||||
|
||||
assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file)));
|
||||
try (var listing = Files.list(dir)) {
|
||||
assertEquals(List.of("users.json"), listing.map(p -> p.getFileName().toString()).toList());
|
||||
}
|
||||
UserStore again = UserStore.load(file);
|
||||
assertEquals(List.of("tata", "mama"), again.usernames());
|
||||
assertTrue(again.authenticate("tata", "correct horse".toCharArray()));
|
||||
assertTrue(again.authenticate("mama", "another password".toCharArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastAccountCannotBeRemoved(@TempDir Path dir) {
|
||||
UserStore users = UserStore.load(dir.resolve("users.json"));
|
||||
users.addFirst("tata", hash);
|
||||
users.add("mama", hash);
|
||||
|
||||
assertEquals(UserStore.Removal.REMOVED, users.remove("mama"));
|
||||
assertEquals(UserStore.Removal.LAST, users.remove("tata"));
|
||||
assertEquals(UserStore.Removal.UNKNOWN, users.remove("mama"));
|
||||
assertEquals(List.of("tata"), UserStore.load(dir.resolve("users.json")).usernames());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWriteLeavesTheStoreAsItWas(@TempDir Path dir) {
|
||||
UserStore users = UserStore.load(dir.resolve("gone/users.json"));
|
||||
|
||||
assertThrows(UncheckedIOException.class, () -> users.addFirst("tata", hash));
|
||||
assertTrue(users.isEmpty());
|
||||
}
|
||||
|
||||
/** An unknown user is run through the same hashing, so the answer does not tell which names exist. */
|
||||
@Test
|
||||
void unknownUserCostsAboutAsMuchAsAWrongPassword(@TempDir Path dir) throws IOException {
|
||||
UserStore users = UserStore.load(file(dir, "[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]"));
|
||||
users.authenticate("tata", "warm up".toCharArray());
|
||||
|
||||
long wrong = nanos(() -> users.authenticate("tata", "nope".toCharArray()));
|
||||
long unknown = nanos(() -> users.authenticate("ghost", "nope".toCharArray()));
|
||||
|
||||
assertTrue(unknown > wrong / 2, "unknown " + unknown + " ns vs wrong " + wrong + " ns");
|
||||
}
|
||||
|
||||
@Test
|
||||
void commandPrintsAHashFromStdin() throws IOException {
|
||||
var out = new ByteArrayOutputStream();
|
||||
|
||||
int code = PasswordHashCli.run(null, stream("s3cr3t-pass\n"), new PrintStream(out), System.err);
|
||||
|
||||
assertEquals(0, code);
|
||||
assertTrue(PasswordHash.matches(out.toString(StandardCharsets.UTF_8).trim(), "s3cr3t-pass".toCharArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void commandRefusesAnEmptyPassword() throws IOException {
|
||||
var err = new ByteArrayOutputStream();
|
||||
|
||||
assertEquals(1, PasswordHashCli.run(null, stream("\n"), System.out, new PrintStream(err)));
|
||||
assertTrue(err.toString(StandardCharsets.UTF_8).contains("No password"));
|
||||
}
|
||||
|
||||
private static ByteArrayInputStream stream(String text) {
|
||||
return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static Path file(Path dir, String content) throws IOException {
|
||||
return Files.writeString(dir.resolve("users-" + content.hashCode() + ".json"), content);
|
||||
}
|
||||
|
||||
private static long nanos(Runnable action) {
|
||||
long start = System.nanoTime();
|
||||
action.run();
|
||||
return System.nanoTime() - start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** The session cookie, the throttle and the other signed cookie. */
|
||||
class SessionsTest {
|
||||
|
||||
private static final byte[] SIGNING_KEY = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
|
||||
private static final Instant T0 = Instant.parse("2026-10-24T10:00:00Z");
|
||||
|
||||
private Instant now = T0;
|
||||
private final Clock clock = new Clock() {
|
||||
@Override
|
||||
public ZoneOffset getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(java.time.ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return now;
|
||||
}
|
||||
};
|
||||
private final SignedTokens tokens = new SignedTokens(SIGNING_KEY, clock);
|
||||
private final Sessions sessions = new Sessions(tokens, clock, Duration.ofDays(30));
|
||||
|
||||
private static String cookieHeader(String setCookie) {
|
||||
return setCookie.split(";")[0];
|
||||
}
|
||||
|
||||
@Test
|
||||
void cookieCarriesTheSessionAndTheRightFlags() {
|
||||
String setCookie = sessions.setCookie("tata", "local");
|
||||
|
||||
assertEquals(Optional.of(new Session("tata", "local", T0)), sessions.read(cookieHeader(setCookie)));
|
||||
for (String flag : new String[] {"HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=2592000"}) {
|
||||
assertTrue(setCookie.contains(flag), flag);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionExpiresAfterItsLifetime() {
|
||||
String cookie = cookieHeader(sessions.setCookie("tata", "oidc"));
|
||||
|
||||
now = T0.plus(Duration.ofDays(30)).minusSeconds(1);
|
||||
assertTrue(sessions.read(cookie).isPresent());
|
||||
now = T0.plus(Duration.ofDays(30));
|
||||
assertTrue(sessions.read(cookie).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamperedOrForeignCookiesAreRefused() {
|
||||
String cookie = cookieHeader(sessions.setCookie("tata", "local"));
|
||||
String other = cookieHeader(sessions.setCookie("mama", "local"));
|
||||
String forgedPayload = other.substring(0, other.indexOf('.')) + cookie.substring(cookie.indexOf('.'));
|
||||
var otherSecret = new Sessions(new SignedTokens("another-secret-another-secret-12".getBytes(StandardCharsets.UTF_8),
|
||||
clock), clock, Duration.ofDays(30));
|
||||
|
||||
assertTrue(sessions.read(forgedPayload).isEmpty());
|
||||
assertTrue(sessions.read(cookie + "x").isEmpty());
|
||||
assertTrue(sessions.read("hp_session=garbage").isEmpty());
|
||||
assertTrue(sessions.read("hp_session=a.b.c").isEmpty());
|
||||
assertTrue(sessions.read(null).isEmpty());
|
||||
assertTrue(otherSecret.read(cookie).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTokenForAnotherPurposeIsNotASession() {
|
||||
String token = tokens.issue("oidc-login", Duration.ofMinutes(10),
|
||||
Map.of("user", "tata", "method", "local", "iat", "0"));
|
||||
|
||||
assertTrue(sessions.read("hp_session=" + token).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearingExpiresTheCookie() {
|
||||
assertTrue(sessions.clearCookie().startsWith("hp_session=; Max-Age=0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void throttleBlocksTheSixthAttemptUntilTheOldestLeavesTheWindow() {
|
||||
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
|
||||
for (int i = 0; i < 4; i++) {
|
||||
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
|
||||
}
|
||||
|
||||
now = T0.plusSeconds(60);
|
||||
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
|
||||
|
||||
assertEquals(15 * 60 - 60 + 1, throttle.tryAcquire("user:tata"));
|
||||
assertEquals(0, throttle.tryAcquire("user:mama", "ip:5.6.7.8"));
|
||||
now = T0.plus(Duration.ofMinutes(15));
|
||||
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void releasedAttemptsAndSuccessDoNotCount() {
|
||||
var throttle = new LoginThrottle(clock, 2, Duration.ofMinutes(15));
|
||||
throttle.tryAcquire("user:tata");
|
||||
throttle.release("user:tata");
|
||||
throttle.tryAcquire("user:tata");
|
||||
assertEquals(0, throttle.tryAcquire("user:tata"));
|
||||
assertTrue(throttle.tryAcquire("user:tata") > 0);
|
||||
|
||||
throttle.cleared("user:tata");
|
||||
|
||||
assertEquals(0, throttle.tryAcquire("user:tata"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parallelAttemptsCannotOutrunTheLimit() throws Exception {
|
||||
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
|
||||
var pool = java.util.concurrent.Executors.newFixedThreadPool(32);
|
||||
var allowed = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var tasks = new java.util.ArrayList<java.util.concurrent.Future<?>>();
|
||||
for (int i = 0; i < 200; i++) {
|
||||
tasks.add(pool.submit(() -> {
|
||||
if (throttle.tryAcquire("user:tata", "ip:1.2.3.4") == 0) {
|
||||
allowed.incrementAndGet();
|
||||
}
|
||||
}));
|
||||
}
|
||||
for (var task : tasks) {
|
||||
task.get();
|
||||
}
|
||||
pool.shutdown();
|
||||
|
||||
assertEquals(5, allowed.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void odditiesInTheProvidersErrorAreCleanedForTheLog() {
|
||||
assertEquals("server_error", OidcFlow.printable("server_error"));
|
||||
assertEquals("a?b?c", OidcFlow.printable("a\nb\rc"));
|
||||
assertEquals(40 + 3, OidcFlow.printable("x".repeat(100)).length());
|
||||
assertEquals("nothing", OidcFlow.printable(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Signature;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** A Keycloak stand-in on localhost: discovery, signing keys and a token endpoint that hands out what a test says. */
|
||||
public final class StubIssuer {
|
||||
|
||||
public static final String CLIENT_ID = "home-pump";
|
||||
public static final String CLIENT_KEY = "stub-client-secret";
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
/** What the token endpoint received. */
|
||||
public record TokenRequest(String authorization, Map<String, String> form) {
|
||||
}
|
||||
|
||||
public final List<TokenRequest> tokenRequests = new ArrayList<>();
|
||||
/** What the token endpoint answers with; set by the test. */
|
||||
public volatile String idTokenToServe = "";
|
||||
public volatile int tokenStatus = 200;
|
||||
|
||||
private final KeyPair keys = newKeys();
|
||||
private final HttpServer server;
|
||||
|
||||
public StubIssuer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/realms/home/.well-known/openid-configuration", exchange -> reply(exchange, 200,
|
||||
Map.of("issuer", issuer().toString(),
|
||||
"authorization_endpoint", issuer() + "/protocol/openid-connect/auth",
|
||||
"token_endpoint", issuer() + "/protocol/openid-connect/token",
|
||||
"jwks_uri", issuer() + "/protocol/openid-connect/certs")));
|
||||
server.createContext("/realms/home/protocol/openid-connect/certs", exchange -> reply(exchange, 200,
|
||||
Map.of("keys", List.of(Map.of("kty", "RSA", "kid", "broken", "n", "***", "e", "AQAB"), jwk()))));
|
||||
server.createContext("/realms/home/protocol/openid-connect/token", this::token);
|
||||
server.start();
|
||||
}
|
||||
|
||||
public URI issuer() {
|
||||
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/realms/home");
|
||||
}
|
||||
|
||||
public java.security.PublicKey publicKey() {
|
||||
return keys.getPublic();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
/** A valid ID token for the nonce, with {@code change} applied to the claims. */
|
||||
public String idToken(String nonce, Consumer<Map<String, Object>> change) {
|
||||
long now = Instant.now().getEpochSecond();
|
||||
Map<String, Object> claims = new LinkedHashMap<>();
|
||||
claims.put("iss", issuer().toString());
|
||||
claims.put("aud", CLIENT_ID);
|
||||
claims.put("sub", "7f1c");
|
||||
claims.put("preferred_username", "tata");
|
||||
claims.put("iat", now);
|
||||
claims.put("exp", now + 300);
|
||||
claims.put("nonce", nonce);
|
||||
claims.put("realm_access", Map.of("roles", List.of("home-pump")));
|
||||
change.accept(claims);
|
||||
return sign("RS256", "k1", claims, keys.getPrivate());
|
||||
}
|
||||
|
||||
/** Signs with any key, so a test can forge a token. */
|
||||
public static String sign(String alg, String kid, Map<String, Object> claims, PrivateKey key) {
|
||||
try {
|
||||
String header = Base64Url.encode(JSON.writeValueAsBytes(Map.of("alg", alg, "kid", kid, "typ", "JWT")));
|
||||
String signed = header + "." + Base64Url.encode(JSON.writeValueAsBytes(claims));
|
||||
Signature rsa = Signature.getInstance("SHA256withRSA");
|
||||
rsa.initSign(key);
|
||||
rsa.update(signed.getBytes(StandardCharsets.US_ASCII));
|
||||
return signed + "." + Base64Url.encode(rsa.sign());
|
||||
} catch (IOException | GeneralSecurityException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static KeyPair newKeys() {
|
||||
try {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
return generator.generateKeyPair();
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> jwk() {
|
||||
var key = (RSAPublicKey) keys.getPublic();
|
||||
return Map.of("kty", "RSA", "use", "sig", "alg", "RS256", "kid", "k1",
|
||||
"n", Base64Url.encode(unsigned(key.getModulus())), "e", Base64Url.encode(unsigned(key.getPublicExponent())));
|
||||
}
|
||||
|
||||
private static byte[] unsigned(BigInteger number) {
|
||||
byte[] bytes = number.toByteArray();
|
||||
return bytes[0] == 0 ? java.util.Arrays.copyOfRange(bytes, 1, bytes.length) : bytes;
|
||||
}
|
||||
|
||||
private void token(HttpExchange exchange) throws IOException {
|
||||
Map<String, String> form = new HashMap<>();
|
||||
for (String pair : new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8).split("&")) {
|
||||
String[] kv = pair.split("=", 2);
|
||||
form.put(URLDecoder.decode(kv[0], StandardCharsets.UTF_8), URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
|
||||
}
|
||||
tokenRequests.add(new TokenRequest(exchange.getRequestHeaders().getFirst("Authorization"), form));
|
||||
reply(exchange, tokenStatus, Map.of("id_token", idTokenToServe, "token_type", "Bearer"));
|
||||
}
|
||||
|
||||
private static void reply(HttpExchange exchange, int status, Object body) throws IOException {
|
||||
byte[] bytes = JSON.writeValueAsBytes(body);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.RefreshResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -65,7 +66,7 @@ class GatewayHttpCommandsTest {
|
||||
}
|
||||
|
||||
private void answerCommands() {
|
||||
for (String path : List.of("/commands", "/battery")) {
|
||||
for (String path : List.of("/commands", "/battery", "/refresh")) {
|
||||
server.createContext(path, this::answer);
|
||||
}
|
||||
}
|
||||
@@ -225,6 +226,29 @@ class GatewayHttpCommandsTest {
|
||||
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
|
||||
void readsTheBatteryReserveWithTheToken() throws Exception {
|
||||
serve(200, contract("battery-reserve.json"));
|
||||
|
||||
@@ -2,9 +2,12 @@ package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.AuditRepository;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.application.GatewayClient;
|
||||
import home.backend.application.GatewayBattery;
|
||||
@@ -16,6 +19,7 @@ import home.backend.domain.BatteryView.Reserve;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.RefreshResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import home.backend.domain.Restore;
|
||||
@@ -24,11 +28,21 @@ import home.backend.domain.SetpointState;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.application.HeatPumpSamples;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.ProgramService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.WeatherService;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -43,12 +57,16 @@ import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -63,10 +81,13 @@ class ApiServerTest {
|
||||
private GatewaySnapshot snapshot;
|
||||
private boolean gatewayDown;
|
||||
private boolean historyDown;
|
||||
private boolean auditDown;
|
||||
private HeatPumpSamples samples;
|
||||
private RuntimeException gatewayBug;
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
private Status verdict;
|
||||
private Outcome restoreOutcome;
|
||||
private int refreshes;
|
||||
private final List<String> restored = new ArrayList<>();
|
||||
private boolean commandsDown;
|
||||
private boolean batteryDown;
|
||||
@@ -95,6 +116,16 @@ class ApiServerTest {
|
||||
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
|
||||
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
|
||||
};
|
||||
samples = (from, to) -> {
|
||||
if (historyDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return List.of(
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 8.0, 44.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 8.5, 43.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 9.0, 45.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 9.0, 46.0, Periods.UNKNOWN));
|
||||
};
|
||||
verdict = Status.APPLIED;
|
||||
restoreOutcome = Outcome.RESTORED;
|
||||
answersBeforeDrop = Integer.MAX_VALUE;
|
||||
@@ -178,7 +209,19 @@ class ApiServerTest {
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
|
||||
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, batteryCommands, clock));
|
||||
new BatteryService(gateway, batteryCommands, clock),
|
||||
new AuditService(auditRows(), clock), new StatsService(samples, clock),
|
||||
new ProgramService((from, to) -> samples.samples(from, to), clock),
|
||||
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
|
||||
(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();
|
||||
}
|
||||
|
||||
@@ -187,15 +230,36 @@ class ApiServerTest {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
private AuditRepository auditRows() {
|
||||
return new AuditRepository() {
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) throws IOException {
|
||||
if (auditDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return List.of(
|
||||
new Row(Instant.parse("2026-10-24T08:00:00Z"), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
|
||||
new Row(Instant.parse("2026-10-23T07:00:00Z"), "co_comfort", "mariusz", "REJECTED", null, 22.0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) {
|
||||
return List.of(
|
||||
new Row(Instant.parse("2026-10-24T09:30:00Z"), "reserve", "mariusz", "APPLIED", 50.0, 60.0),
|
||||
new Row(Instant.parse("2026-10-23T18:00:00Z"), "174", "mariusz", "FAILED", 0.0, 1.0));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static GatewaySnapshot exampleSnapshot() {
|
||||
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
|
||||
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
|
||||
"pump_outlet_temp", 19.7, "outdoor_avg_temp", 4.8, "target_room_temp", 18.0));
|
||||
pump.putAll(Map.of("target_co_temp", 31.0, "target_cwu_temp", 8.0, "pump_mode", 0.0,
|
||||
"output_compressor", 0.0, "output_heater", 0.0, "alarm", 0.0,
|
||||
"output_compressor", 0.0, "alarm", 0.0,
|
||||
"setpoint_co_economy", 18.0, "setpoint_co_comfort", 19.0, "setpoint_cwu_economy", 8.0));
|
||||
pump.putAll(Map.of("setpoint_cwu_comfort", 45.0, "output_co", 0.0, "output_buffer", 0.0,
|
||||
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
|
||||
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0, "program", 1.0));
|
||||
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
|
||||
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
|
||||
Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||
@@ -216,7 +280,7 @@ class ApiServerTest {
|
||||
new DeviceSnapshot("house-meter", true, energyTime, null,
|
||||
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
|
||||
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
|
||||
"frequency", 50.01, "import_energy_active", 67514.27), energyTime))),
|
||||
"frequency", 50.01, "import_energy_active", 67514.27, "total_power_active", 388.78), energyTime))),
|
||||
new DeviceSnapshot("deye-settings", true, energyTime, null,
|
||||
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
|
||||
new DeviceSnapshot("bme280", true, energyTime, null,
|
||||
@@ -332,13 +396,12 @@ class ApiServerTest {
|
||||
|
||||
@Test
|
||||
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
|
||||
var response = post("/api/battery/schedule", example("battery-schedule-request").toString(),
|
||||
"Remote-User", "anna");
|
||||
var response = post("/api/battery/schedule", example("battery-schedule-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
|
||||
new BatteryChange.Slot(2, java.time.LocalTime.of(9, 30), 90, true),
|
||||
new BatteryChange.Slot(3, null, 85, null))) + " by anna"), batterySent);
|
||||
new BatteryChange.Slot(3, null, 85, null))) + " by phone"), batterySent);
|
||||
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
|
||||
JSON.readTree(response.body()));
|
||||
}
|
||||
@@ -400,6 +463,37 @@ class ApiServerTest {
|
||||
assertTrue(batterySent.isEmpty());
|
||||
}
|
||||
|
||||
private static Forecast exampleForecast() {
|
||||
Map<LocalDate, Forecast.DailyForecast> days = new TreeMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
days.put(LocalDate.parse("2026-10-19").plusDays(i), new Forecast.DailyForecast(3, 12.0, 4.0, 0.0, 10.0, 7200.0));
|
||||
}
|
||||
days.put(LocalDate.parse("2026-10-24"), new Forecast.DailyForecast(3, 10.9, 3.8, 0.0, 9.0, 7151.0));
|
||||
days.put(LocalDate.parse("2026-10-25"), new Forecast.DailyForecast(51, 13.2, 4.0, 0.3, 4.5, 28314.0));
|
||||
days.put(LocalDate.parse("2026-10-26"), new Forecast.DailyForecast(61, 9.6, 4.1, 8.4, 2.1, 0.0));
|
||||
return new Forecast(10.6, days);
|
||||
}
|
||||
|
||||
/** Twice the forecast radiation of the same days, so the factor is 2 kWh per MJ/m². */
|
||||
private static Map<LocalDate, Double> exampleMeasuredPv() {
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
measured.put(LocalDate.parse("2026-10-19").plusDays(i), 20.0);
|
||||
}
|
||||
return measured;
|
||||
}
|
||||
|
||||
private static Warning exampleWarning() {
|
||||
return new Warning("Silny wiatr", 2, OffsetDateTime.parse("2026-10-25T08:00:00+01:00"),
|
||||
OffsetDateTime.parse("2026-10-25T18:00:00+01:00"),
|
||||
"Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void weatherMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/weather", "weather");
|
||||
}
|
||||
|
||||
@Test
|
||||
void piSensorMatchesExample()throws Exception {
|
||||
assertMatchesExample("/api/pi-sensor", "pi-sensor");
|
||||
@@ -421,11 +515,80 @@ class ApiServerTest {
|
||||
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/stats?range=7d", "stats");
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsNeedAKnownRange() throws Exception {
|
||||
assertEquals(400, get("/api/stats").statusCode());
|
||||
assertEquals(400, get("/api/stats?range=24h").statusCode());
|
||||
assertEquals(200, get("/api/stats?range=30d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsAre502WhenInfluxIsDown() throws Exception {
|
||||
historyDown = true;
|
||||
|
||||
assertEquals(502, get("/api/stats?range=7d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void programMatchesExample() throws Exception {
|
||||
// Friday 2026-10-23, CEST: CWU comfort from 11:25 (09:25 UTC) to 12:05.
|
||||
Periods economy = Periods.of(1, new Targets(18.0, 31.0, 8.0), new Setpoints(18, 19, 8, 43));
|
||||
Periods comfort = Periods.of(1, new Targets(18.0, 31.0, 43.0), new Setpoints(18, 19, 8, 43));
|
||||
samples = (from, to) -> {
|
||||
List<HeatPumpSample> polls = new ArrayList<>();
|
||||
for (int minute = 20; minute <= 125; minute += 5) {
|
||||
Instant t = Instant.parse("2026-10-23T09:00:00Z").plus(Duration.ofMinutes(minute));
|
||||
polls.add(new HeatPumpSample(t, null, null, null, null, null,
|
||||
minute >= 25 && minute <= 60 ? comfort : economy));
|
||||
}
|
||||
return polls;
|
||||
};
|
||||
|
||||
assertMatchesExample("/api/program?days=7", "program");
|
||||
}
|
||||
|
||||
@Test
|
||||
void programNeedsDaysFromSevenToThirty() throws Exception {
|
||||
assertEquals(200, get("/api/program").statusCode());
|
||||
assertEquals(400, get("/api/program?days=3").statusCode());
|
||||
assertEquals(400, get("/api/program?days=x").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void programIs502WhenInfluxIsDown() throws Exception {
|
||||
historyDown = true;
|
||||
|
||||
assertEquals(502, get("/api/program").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyRangeDefaultsTo24Hours() throws Exception {
|
||||
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/audit?range=7d", "audit");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditRangeDefaultsAndIsChecked() throws Exception {
|
||||
assertEquals(200, get("/api/audit").statusCode());
|
||||
assertEquals(400, get("/api/audit?range=90d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditInfluxFailureIs502() throws Exception {
|
||||
auditDown = true;
|
||||
|
||||
assertEquals(502, get("/api/audit?range=7d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayIs503WithJsonError() throws Exception {
|
||||
gatewayDown = true;
|
||||
@@ -517,11 +680,11 @@ class ApiServerTest {
|
||||
|
||||
@Test
|
||||
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
|
||||
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
|
||||
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
|
||||
assertEquals(List.of("cwu_economy by anna"), restored);
|
||||
assertEquals(List.of("cwu_economy by phone"), restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -552,6 +715,32 @@ class ApiServerTest {
|
||||
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
|
||||
void otherPathsUnderSetpointsAre404() throws Exception {
|
||||
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
|
||||
@@ -579,12 +768,10 @@ class ApiServerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception {
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl");
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
|
||||
post("/api/setpoints", BOOST);
|
||||
void aRemoteUserHeaderIsNotTrusted() throws Exception {
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna");
|
||||
|
||||
assertEquals(List.of("anna.k@example.pl", "phone", "phone"), sent.stream().map(Command::requestedBy).toList());
|
||||
assertEquals(List.of("phone"), sent.stream().map(Command::requestedBy).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -676,6 +863,25 @@ class ApiServerTest {
|
||||
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void timedPresetTakesOwnHoursAndValue() throws Exception {
|
||||
var response = post("/api/presets/cwu-now", example("preset-cwu-now-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(List.of(new Command("cwu_economy", 47, Duration.ofHours(3), "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidHoursAre400AndSendNothing() throws Exception {
|
||||
for (String hours : List.of("0", "5", "-1", "2.5", "\"2\"")) {
|
||||
assertEquals(400, post("/api/presets/cwu-now", "{\"hours\":" + hours + "}").statusCode(), hours);
|
||||
}
|
||||
var held = post("/api/presets/away", "{\"until\":\"2026-11-02T18:00:00Z\",\"hours\":2}");
|
||||
assertEquals(400, held.statusCode());
|
||||
assertEquals("invalid_hours", JSON.readTree(held.body()).get("error").asText());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIs404() throws Exception {
|
||||
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.application.AuditRepository.Row;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class InfluxAuditRepositoryTest {
|
||||
|
||||
private static final Instant FROM = Instant.parse("2026-10-17T12:00:00Z");
|
||||
private static final Instant TO = Instant.parse("2026-10-24T12:00:00Z");
|
||||
|
||||
private HttpServer server;
|
||||
private final List<String> queries = Collections.synchronizedList(new ArrayList<>());
|
||||
private String body;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/query", exchange -> {
|
||||
queries.add(URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsHeatPumpRowsFromTheLegacyDatabase() throws IOException {
|
||||
body = fixture("influx-audit-setpoints.json");
|
||||
|
||||
List<Row> rows = repository().heatPump(FROM, TO);
|
||||
|
||||
assertEquals(2, rows.size());
|
||||
assertEquals(new Row(Instant.ofEpochMilli(1761292800000L), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
|
||||
rows.get(0));
|
||||
assertNull(rows.get(1).oldValue());
|
||||
assertEquals("co_comfort", rows.get(1).target());
|
||||
assertTrue(queries.getFirst().startsWith("db=Dom_Pompa&epoch=ms&q=SELECT * FROM \"setpoint_audit\" "
|
||||
+ "WHERE time >= " + FROM.toEpochMilli() + "ms AND time < " + TO.toEpochMilli() + "ms"),
|
||||
queries.getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsBatteryRowsByRegisterTag() throws IOException {
|
||||
body = fixture("influx-audit-battery.json");
|
||||
|
||||
List<Row> rows = repository().battery(FROM, TO);
|
||||
|
||||
assertEquals(List.of("reserve", "174"), rows.stream().map(Row::target).toList());
|
||||
assertEquals(60.0, rows.getFirst().newValue());
|
||||
assertTrue(queries.getFirst().startsWith("db=electricity&"), queries.getFirst());
|
||||
assertTrue(queries.getFirst().contains("\"deye_settings_audit\""), queries.getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noRowsIsAnEmptyList() throws IOException {
|
||||
body = fixture("influx-query-empty.json");
|
||||
|
||||
assertTrue(repository().battery(FROM, TO).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anInfluxErrorIsAnIOException() throws IOException {
|
||||
body = fixture("influx-query-error.json");
|
||||
|
||||
assertThrows(IOException.class, () -> repository().heatPump(FROM, TO));
|
||||
}
|
||||
|
||||
private InfluxAuditRepository repository() {
|
||||
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
return new InfluxAuditRepository(new InfluxSettings(base, Duration.ofSeconds(5)));
|
||||
}
|
||||
|
||||
private static String fixture(String name) throws IOException {
|
||||
return Files.readString(Path.of("src/test/resources/influx", name));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.Period;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -208,6 +211,38 @@ class InfluxHistoryRepositoryTest {
|
||||
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsRawHeatPumpPollsFromTheLegacyDatabase() throws IOException {
|
||||
defaultBody = fixture("influx-query-heat-pump.json");
|
||||
|
||||
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
|
||||
|
||||
assertEquals("Dom_Pompa", requests.get(0).get("db"));
|
||||
assertEquals("SELECT output_compressor, output_cwu, output_co, outdoor_temp, cwu_temp,"
|
||||
+ " program, target_room_temp, target_cwu_temp, setpoint_co_economy, setpoint_co_comfort,"
|
||||
+ " setpoint_cwu_economy, setpoint_cwu_comfort"
|
||||
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
|
||||
requests.get(0).get("q"));
|
||||
assertEquals(3, samples.size());
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 8.5, 43.9,
|
||||
new Periods(Period.COMFORT, Period.COMFORT, 1, null)), samples.get(1));
|
||||
assertEquals(Period.ECONOMY, samples.get(0).periods().cwu());
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, null, null, Periods.UNKNOWN),
|
||||
samples.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noHeatPumpPollsIsAnEmptyList() throws IOException {
|
||||
assertTrue(repository(TIMEOUT).samples(FROM, TO).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpQueryErrorIsAnIoException() throws IOException {
|
||||
defaultBody = fixture("influx-query-error.json");
|
||||
|
||||
assertThrows(IOException.class, () -> repository(TIMEOUT).samples(FROM, TO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnEmptyRange() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class InfluxPvEnergyTest {
|
||||
|
||||
private static final LocalDate FROM = LocalDate.parse("2026-10-19");
|
||||
private static final LocalDate TO = LocalDate.parse("2026-10-24");
|
||||
|
||||
private HttpServer server;
|
||||
private InfluxPvEnergy pv;
|
||||
private final List<String> queries = new ArrayList<>();
|
||||
private int status = 200;
|
||||
/** Day buckets start at Warsaw midnight: 2026-10-19 is 2026-10-18T22:00Z. */
|
||||
private String deye = rows("1792360800000", 22.5, "1792447200000", 8.0);
|
||||
private String fronius = rows("1792360800000", 4400.0, "1792533600000", 100.0);
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/query", exchange -> {
|
||||
String query = URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8);
|
||||
queries.add(query);
|
||||
byte[] bytes = (query.contains("\"deye\"") ? deye : fronius).getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
var settings = new InfluxSettings(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
|
||||
Duration.ofSeconds(2));
|
||||
pv = new InfluxPvEnergy(settings);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private static String rows(String t1, double v1, String t2, double v2) {
|
||||
return "{\"results\":[{\"statement_id\":0,\"series\":[{\"name\":\"m\",\"columns\":[\"time\",\"max\"],"
|
||||
+ "\"values\":[[" + t1 + "," + v1 + "],[" + t2 + "," + v2 + "]]}]}]}";
|
||||
}
|
||||
|
||||
@Test
|
||||
void addsTheDeyeAndFroniusDayCountersPerLocalDay() throws IOException {
|
||||
Map<LocalDate, Double> days = pv.dailyKwh(FROM, TO);
|
||||
|
||||
assertEquals(Map.of(FROM, 22.5 + 4.4), days);
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksForTheDailyMaximumOfEachCounterInWarsawDays() throws IOException {
|
||||
pv.dailyKwh(FROM, TO);
|
||||
|
||||
assertEquals(2, queries.size());
|
||||
String deyeQuery = queries.stream().filter(q -> q.contains("\"deye\"")).findFirst().orElseThrow();
|
||||
assertTrue(deyeQuery.contains("db=electricity"), deyeQuery);
|
||||
assertTrue(deyeQuery.contains("SELECT max(\"pv_today_kwh\") FROM \"deye\" WHERE time >= 1792360800000ms"
|
||||
+ " AND time < 1792792800000ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')"), deyeQuery);
|
||||
assertTrue(queries.stream().anyMatch(q -> q.contains("max(\"energy_day_wh\") FROM \"fronius\"")), queries.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failureIsAnIoError() {
|
||||
status = 500;
|
||||
|
||||
assertThrows(IOException.class, () -> pv.dailyKwh(FROM, TO));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** The two clients against a local server that answers with responses recorded from the real services. */
|
||||
class WeatherClientsTest {
|
||||
|
||||
private static final WeatherSettings PLACE = WeatherSettings.fromEnvironment(Map.of());
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(2);
|
||||
|
||||
private HttpServer server;
|
||||
private URI base;
|
||||
private final List<String> requests = new ArrayList<>();
|
||||
private int status = 200;
|
||||
private String body = "";
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
requests.add(exchange.getRequestURI().toString());
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private static String fixture(String name) throws IOException {
|
||||
return Files.readString(Path.of("src/test/resources/weather", name));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheOpenMeteoForecastForTheHouse() throws IOException {
|
||||
body = fixture("open-meteo.json");
|
||||
|
||||
Forecast forecast = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast();
|
||||
|
||||
assertEquals(10.6, forecast.temperature());
|
||||
assertEquals(17, forecast.days().size());
|
||||
var today = forecast.days().get(LocalDate.parse("2026-10-24"));
|
||||
assertEquals(3, today.weatherCode());
|
||||
assertEquals(10.9, today.tMax());
|
||||
assertEquals(3.8, today.tMin());
|
||||
assertEquals(6.88, today.radiationMj());
|
||||
assertEquals(7151.32, today.sunshineSeconds());
|
||||
assertEquals(8.4, forecast.days().get(LocalDate.parse("2026-10-26")).precipitationMm());
|
||||
String request = requests.getFirst();
|
||||
for (String part : List.of("latitude=49.684", "longitude=21.77", "timezone=Europe%2FWarsaw",
|
||||
"past_days=14", "shortwave_radiation_sum", "sunshine_duration")) {
|
||||
assertEquals(true, request.contains(part), part + " in " + request);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingValueInTheForecastIsNull() throws IOException {
|
||||
body = """
|
||||
{"daily":{"time":["2026-10-24"],"weather_code":[null],"temperature_2m_max":[1.5]}}""";
|
||||
|
||||
var day = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast().days().get(LocalDate.parse("2026-10-24"));
|
||||
|
||||
assertNull(day.weatherCode());
|
||||
assertEquals(1.5, day.tMax());
|
||||
assertNull(day.tMin());
|
||||
assertNull(day.radiationMj());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOpenMeteoFailureIsAnIoError() {
|
||||
body = "{\"error\":true}";
|
||||
status = 500;
|
||||
var client = new OpenMeteoClient(base, PLACE, TIMEOUT);
|
||||
assertThrows(IOException.class, client::forecast);
|
||||
|
||||
status = 200;
|
||||
body = "not json";
|
||||
assertThrows(IOException.class, client::forecast);
|
||||
|
||||
body = "{\"daily\":{}}";
|
||||
assertThrows(IOException.class, client::forecast);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsWarningsThatCoverTheCityOrThePowiatAndMapsThem() throws IOException {
|
||||
body = fixture("imgw-warnings.json");
|
||||
|
||||
List<Warning> warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings();
|
||||
|
||||
assertEquals("/api/data/warningsmeteo", requests.getFirst());
|
||||
assertEquals(2, warnings.size());
|
||||
Warning city = warnings.get(0);
|
||||
assertEquals("Silny wiatr", city.event());
|
||||
assertEquals(2, city.level());
|
||||
assertEquals(OffsetDateTime.parse("2026-10-25T08:00:00+01:00"), city.from());
|
||||
assertEquals(OffsetDateTime.parse("2026-10-25T18:00:00+01:00"), city.to());
|
||||
assertEquals("Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h, "
|
||||
+ "w porywach do 90 km/h, z południa.", city.text());
|
||||
assertEquals(1, warnings.get(1).level());
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherAreasAreIgnoredAndTerytIsConfigurable() throws IOException {
|
||||
body = fixture("imgw-warnings.json");
|
||||
var cityOnly = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("1862"));
|
||||
var elsewhere = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("2804"));
|
||||
|
||||
assertEquals(1, new ImgwClient(base, cityOnly, TIMEOUT).warnings().size());
|
||||
assertEquals(1, new ImgwClient(base, elsewhere, TIMEOUT).warnings().size());
|
||||
assertEquals(0, new ImgwClient(base, new WeatherSettings(0, 0, List.of("0000")), TIMEOUT).warnings().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMalformedWarningIsSkippedNotFatal() throws IOException {
|
||||
body = """
|
||||
[{"nazwa_zdarzenia":"Burze","stopien":"x","obowiazuje_od":"2026-10-25 08:00:00",
|
||||
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"a","teryt":["1862"]},
|
||||
{"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"soon",
|
||||
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"b","teryt":["1862"]},
|
||||
{"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"2026-10-25 08:00:00",
|
||||
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"c","teryt":["1862"]}]""";
|
||||
|
||||
List<Warning> warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings();
|
||||
|
||||
assertEquals(1, warnings.size());
|
||||
assertEquals("c", warnings.getFirst().text());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anImgwFailureIsAnIoError() {
|
||||
body = "{}";
|
||||
var client = new ImgwClient(base, PLACE, TIMEOUT);
|
||||
assertThrows(IOException.class, client::warnings);
|
||||
|
||||
status = 503;
|
||||
assertThrows(IOException.class, client::warnings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsDefaultToKrosnoAndRejectNonsense() {
|
||||
assertEquals(49.684, PLACE.latitude());
|
||||
assertEquals(21.770, PLACE.longitude());
|
||||
assertEquals(List.of("1862", "1807"), PLACE.teryt());
|
||||
|
||||
var custom = WeatherSettings.fromEnvironment(
|
||||
Map.of("WEATHER_LAT", "50.1", "WEATHER_LON", "-20", "WEATHER_TERYT", "1465, 1401"));
|
||||
assertEquals(50.1, custom.latitude());
|
||||
assertEquals(-20.0, custom.longitude());
|
||||
assertEquals(List.of("1465", "1401"), custom.teryt());
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LAT", "north")));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LON", "200")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.AuditRepository.Row;
|
||||
import home.backend.domain.AuditEntry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class AuditServiceTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
|
||||
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
|
||||
private List<Row> heatPump = List.of();
|
||||
private List<Row> battery = List.of();
|
||||
private final List<Instant> asked = new ArrayList<>();
|
||||
|
||||
private final AuditRepository repository = new AuditRepository() {
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) {
|
||||
asked.add(from);
|
||||
return heatPump;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) throws IOException {
|
||||
if (battery == null) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return battery;
|
||||
}
|
||||
};
|
||||
private final AuditService service = new AuditService(repository, CLOCK);
|
||||
|
||||
private static Row row(String time, String target, String by, Double old, Double value) {
|
||||
return new Row(Instant.parse(time), target, by, "APPLIED", old, value);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergesBothSourcesNewestFirst() throws IOException {
|
||||
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "a", 8.0, 45.0),
|
||||
row("2026-10-22T08:00:00Z", "co_comfort", "a", 19.0, 20.0));
|
||||
battery = List.of(row("2026-10-23T08:00:00Z", "reserve", "b", 50.0, 60.0));
|
||||
|
||||
var entries = service.audit(null).entries();
|
||||
|
||||
assertEquals(List.of("Ciepła woda – oszczędnie", "Rezerwa baterii", "Ogrzewanie – komfort"),
|
||||
entries.stream().map(AuditEntry::what).toList());
|
||||
assertEquals(List.of("heat_pump", "battery", "heat_pump"),
|
||||
entries.stream().map(AuditEntry::source).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namesTheDeyeRegisters() throws IOException {
|
||||
battery = List.of("146", "148", "153", "166", "171", "172", "177", "reserve", "999").stream()
|
||||
.map(register -> row("2026-10-24T08:00:00Z", register, "a", 0.0, 1.0)).toList();
|
||||
|
||||
var entries = service.audit("24h").entries();
|
||||
|
||||
assertEquals(List.of("Harmonogram baterii/0/1", "Przedział 1: start/HHMM", "Przedział 6: start/HHMM",
|
||||
"Przedział 1: poziom/%", "Przedział 6: poziom/%", "Przedział 1: ładowanie z sieci/0/1",
|
||||
"Przedział 6: ładowanie z sieci/0/1", "Rezerwa baterii/%", "Rejestr 999/"),
|
||||
entries.stream().map(e -> e.what() + "/" + e.unit()).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsARestoreAsAutomaticAndKeepsMissingValuesNull() throws IOException {
|
||||
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "restore", null, 8.4));
|
||||
|
||||
var entry = service.audit("7d").entries().getFirst();
|
||||
|
||||
assertEquals("automatycznie (przywrócenie)", entry.who());
|
||||
assertNull(entry.oldValue());
|
||||
assertEquals(8, entry.newValue());
|
||||
assertEquals("°C", entry.unit());
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsToSevenDaysAndAllowsUpToThirty() throws IOException {
|
||||
service.audit(null);
|
||||
service.audit("30d");
|
||||
|
||||
assertEquals(List.of(NOW.minus(Duration.ofDays(7)), NOW.minus(Duration.ofDays(30))), asked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnknownRange() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.audit("90d"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.audit(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenOneSourceFails() {
|
||||
battery = null;
|
||||
|
||||
assertThrows(IOException.class, () -> service.audit("7d"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.ProgramView;
|
||||
import home.backend.domain.ProgramView.Circuit;
|
||||
import home.backend.domain.ProgramView.Confidence;
|
||||
import home.backend.domain.ProgramView.Interval;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class ProgramServiceTest {
|
||||
|
||||
/** Tuesday 2026-10-27 12:00 in Warsaw; the Sunday before it, 10-25, is the day of the change to winter time. */
|
||||
private static final Instant NOW = Instant.parse("2026-10-27T11:00:00Z");
|
||||
private static final int POLL_MINUTES = 5;
|
||||
|
||||
private Instant askedFrom;
|
||||
private Instant askedTo;
|
||||
|
||||
private ProgramView program(String days, List<HeatPumpSample> samples) throws IOException {
|
||||
HeatPumpSamples repository = (from, to) -> {
|
||||
askedFrom = from;
|
||||
askedTo = to;
|
||||
return samples;
|
||||
};
|
||||
return new ProgramService(repository, Clock.fixed(NOW, ZoneOffset.UTC)).program(days);
|
||||
}
|
||||
|
||||
private static HeatPumpSample poll(String utc, Periods periods) {
|
||||
return new HeatPumpSample(Instant.parse(utc), null, null, null, null, null, periods);
|
||||
}
|
||||
|
||||
/** CWU economy 8, comfort 43; room economy 18, comfort 19, as on the real controller. */
|
||||
private static Periods weekly(double cwuTarget, double roomTarget) {
|
||||
return program(1, cwuTarget, roomTarget, 8, 43);
|
||||
}
|
||||
|
||||
private static Periods program(int program, double cwuTarget, double roomTarget, int cwuEconomy, int cwuComfort) {
|
||||
return Periods.of(program, new Targets(roomTarget, 31.0, cwuTarget),
|
||||
new Setpoints(18, 19, cwuEconomy, cwuComfort));
|
||||
}
|
||||
|
||||
private static Periods economy() {
|
||||
return weekly(8.0, 18.0);
|
||||
}
|
||||
|
||||
private static Periods cwuComfort() {
|
||||
return weekly(43.0, 18.0);
|
||||
}
|
||||
|
||||
/** Polls every 5 minutes from {@code from} to {@code to} (UTC, "2026-10-19T09:00"), all of them {@code periods}. */
|
||||
private static List<HeatPumpSample> polls(String from, String to, Periods periods) {
|
||||
List<HeatPumpSample> samples = new ArrayList<>();
|
||||
Instant end = Instant.parse(to + ":00Z");
|
||||
for (Instant t = Instant.parse(from + ":00Z"); !t.isAfter(end); t = t.plus(Duration.ofMinutes(POLL_MINUTES))) {
|
||||
samples.add(new HeatPumpSample(t, null, null, null, null, null, periods));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private static List<HeatPumpSample> concat(List<HeatPumpSample>... parts) {
|
||||
return Arrays.stream(parts).flatMap(List::stream).toList();
|
||||
}
|
||||
|
||||
/** "2026-10-19T10:25" for a local time of that date, given the offset from UTC. */
|
||||
private static String utc(String date, String local, int offsetHours, int plusMinutes) {
|
||||
int total = Integer.parseInt(local.substring(0, 2)) * 60 + Integer.parseInt(local.substring(3))
|
||||
- offsetHours * 60 + plusMinutes;
|
||||
return String.format("%sT%02d:%02d", date, total / 60, total % 60);
|
||||
}
|
||||
|
||||
/** CWU economy, then comfort from {@code from} to {@code to} (local), then economy, polled every 5 minutes. */
|
||||
private static List<HeatPumpSample> cwuDay(String date, String from, String to, int offsetHours) {
|
||||
return concat(
|
||||
polls(utc(date, from, offsetHours, -30), utc(date, from, offsetHours, -5), economy()),
|
||||
polls(utc(date, from, offsetHours, 0), utc(date, to, offsetHours, -5), cwuComfort()),
|
||||
polls(utc(date, to, offsetHours, 0), utc(date, to, offsetHours, 30), economy()));
|
||||
}
|
||||
|
||||
private static Circuit monday(ProgramView view) {
|
||||
return view.weekdays().get(0).cwu();
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksForWholeLocalDaysBeforeToday() throws IOException {
|
||||
ProgramView view = program("14", List.of());
|
||||
|
||||
assertEquals(14, view.days());
|
||||
assertEquals(Instant.parse("2026-10-12T22:00:00Z"), askedFrom);
|
||||
assertEquals(Instant.parse("2026-10-26T23:00:00Z"), askedTo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsToFourteenDaysAndListsMondayToSunday() throws IOException {
|
||||
ProgramView view = program(null, List.of());
|
||||
|
||||
assertEquals(14, view.days());
|
||||
assertEquals(List.of(1, 2, 3, 4, 5, 6, 7), view.weekdays().stream().map(ProgramView.Weekday::day).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDaysOutsideSevenToThirty() {
|
||||
for (String days : new String[] {"6", "31", "abc", ""}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> program(days, List.of()), days);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDataGivesNoDataForEveryWeekday() throws IOException {
|
||||
Circuit circuit = monday(program("14", List.of()));
|
||||
|
||||
assertEquals(Confidence.NO_DATA, circuit.confidence());
|
||||
assertEquals(0, circuit.observedDays());
|
||||
assertEquals(List.of(), circuit.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBoundaryIsHalfwayBetweenTheTwoPollsRoundedToFiveMinutes() throws IOException {
|
||||
// Economy at 11:23:48 local (CEST), comfort at 11:28:48: the change was about 11:26, so 11:25.
|
||||
// Comfort at 12:04:56, economy at 12:09:56: about 12:07, so 12:05.
|
||||
List<HeatPumpSample> samples = concat(
|
||||
List.of(poll("2026-10-19T09:23:48Z", economy()), poll("2026-10-19T09:28:48Z", cwuComfort())),
|
||||
polls("2026-10-19T09:33", "2026-10-19T10:00", cwuComfort()),
|
||||
List.of(poll("2026-10-19T10:04:56Z", cwuComfort()), poll("2026-10-19T10:09:56Z", economy())));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(List.of(new Interval("11:25", "12:05")), circuit.comfort());
|
||||
assertEquals(Confidence.SEEN_ONCE, circuit.confidence());
|
||||
assertEquals(1, circuit.observedDays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIntervalsOnTwoMondaysWithinFiveMinutesAreConfirmed() throws IOException {
|
||||
List<HeatPumpSample> samples = concat(
|
||||
cwuDay("2026-10-19", "11:25", "12:05", 2), cwuDay("2026-10-26", "11:30", "12:05", 1));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(Confidence.CONFIRMED, circuit.confidence());
|
||||
assertEquals(2, circuit.observedDays());
|
||||
assertEquals(2, circuit.agreeingDays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentIntervalsOnTwoMondaysAreSeenOnceAndTheLatestIsShown() throws IOException {
|
||||
List<HeatPumpSample> samples = concat(
|
||||
cwuDay("2026-10-19", "11:25", "12:05", 2), cwuDay("2026-10-26", "13:00", "14:00", 1));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(Confidence.SEEN_ONCE, circuit.confidence());
|
||||
assertEquals(2, circuit.observedDays());
|
||||
assertEquals(1, circuit.agreeingDays());
|
||||
assertEquals(List.of(new Interval("13:00", "14:00")), circuit.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theMajorityPatternWinsOverOneOddDay() throws IOException {
|
||||
List<HeatPumpSample> samples = concat(
|
||||
cwuDay("2026-10-12", "11:25", "12:05", 2), cwuDay("2026-10-19", "11:25", "12:05", 2),
|
||||
cwuDay("2026-10-26", "13:00", "14:00", 1));
|
||||
|
||||
Circuit circuit = monday(program("30", samples));
|
||||
|
||||
assertEquals(Confidence.CONFIRMED, circuit.confidence());
|
||||
assertEquals(List.of(new Interval("11:25", "12:05")), circuit.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDayWithTheChangeToWinterTimeKeepsLocalClockTimes() throws IOException {
|
||||
// Sunday 2026-10-18 is CEST (UTC+2), Sunday 2026-10-25 CET (UTC+1): the same local 11:25-12:05.
|
||||
List<HeatPumpSample> samples = concat(
|
||||
cwuDay("2026-10-18", "11:25", "12:05", 2), cwuDay("2026-10-25", "11:25", "12:05", 1));
|
||||
|
||||
Circuit sunday = program("14", samples).weekdays().get(6).cwu();
|
||||
|
||||
assertEquals(Confidence.CONFIRMED, sunday.confidence());
|
||||
assertEquals(List.of(new Interval("11:25", "12:05")), sunday.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anIntervalAcrossMidnightIsSplitOverBothDays() throws IOException {
|
||||
// Comfort from 22:55 local Sunday to 01:05 local Monday (CET: UTC+1).
|
||||
List<HeatPumpSample> samples = concat(
|
||||
polls("2026-11-01T21:50", "2026-11-01T21:50", economy()),
|
||||
polls("2026-11-01T21:55", "2026-11-02T00:00", cwuComfort()),
|
||||
polls("2026-11-02T00:05", "2026-11-02T00:10", economy()));
|
||||
Clock later = Clock.fixed(Instant.parse("2026-11-04T11:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
ProgramView view = new ProgramService((from, to) -> samples, later).program("14");
|
||||
|
||||
assertEquals(List.of(new Interval("22:55", "24:00")), view.weekdays().get(6).cwu().comfort());
|
||||
assertEquals(List.of(new Interval("00:00", "01:05")), view.weekdays().get(0).cwu().comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ourOwnWritesLeaveNoComfortIntervalButTheDayStaysObserved() throws IOException {
|
||||
// Economy raised 8 -> 45 -> 43 (equal to comfort, so the periods are ambiguous) -> 8, as in the
|
||||
// 2026-10-24 tests, all during the economy period.
|
||||
List<HeatPumpSample> samples = List.of(
|
||||
poll("2026-10-19T11:00:00Z", program(1, 8.0, 18.0, 8, 43)),
|
||||
poll("2026-10-19T11:05:00Z", program(1, 45.0, 18.0, 45, 43)),
|
||||
poll("2026-10-19T11:10:00Z", program(1, 43.0, 18.0, 43, 43)),
|
||||
poll("2026-10-19T11:15:00Z", program(1, 43.0, 18.0, 43, 43)),
|
||||
poll("2026-10-19T11:20:00Z", program(1, 8.0, 18.0, 8, 43)));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(List.of(), circuit.comfort());
|
||||
assertEquals(1, circuit.observedDays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWriteInsideComfortDropsTheIntervalInsteadOfGuessingItsEnd() throws IOException {
|
||||
// Economy written 8 -> 43 inside the comfort period makes the CWU periods ambiguous for two polls.
|
||||
List<HeatPumpSample> samples = concat(
|
||||
polls("2026-10-19T09:55", "2026-10-19T09:55", economy()),
|
||||
polls("2026-10-19T10:00", "2026-10-19T10:05", cwuComfort()),
|
||||
polls("2026-10-19T10:10", "2026-10-19T10:15", program(1, 43.0, 18.0, 43, 43)),
|
||||
polls("2026-10-19T10:20", "2026-10-19T10:55", cwuComfort()),
|
||||
polls("2026-10-19T11:00", "2026-10-19T11:05", economy()));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(List.of(), circuit.comfort());
|
||||
assertEquals(1, circuit.observedDays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anotherProgramBreaksTheChain() throws IOException {
|
||||
List<HeatPumpSample> samples = List.of(
|
||||
poll("2026-10-19T09:55:00Z", economy()), poll("2026-10-19T10:00:00Z", cwuComfort()),
|
||||
poll("2026-10-19T10:05:00Z", program(2, 43.0, 18.0, 8, 43)),
|
||||
poll("2026-10-19T10:10:00Z", economy()));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(List.of(), circuit.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGapInTheDataBreaksTheChain() throws IOException {
|
||||
List<HeatPumpSample> samples = List.of(
|
||||
poll("2026-10-19T09:55:00Z", economy()), poll("2026-10-19T10:00:00Z", cwuComfort()),
|
||||
poll("2026-10-19T10:40:00Z", economy()));
|
||||
|
||||
assertEquals(List.of(), monday(program("14", samples)).comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roomIsReadFromItsOwnTargets() throws IOException {
|
||||
List<HeatPumpSample> samples = concat(
|
||||
polls("2026-10-19T09:55", "2026-10-19T09:55", weekly(8.0, 18.0)),
|
||||
polls("2026-10-19T10:00", "2026-10-19T10:55", weekly(8.0, 19.0)),
|
||||
polls("2026-10-19T11:00", "2026-10-19T11:00", weekly(8.0, 18.0)));
|
||||
|
||||
ProgramView view = program("14", samples);
|
||||
|
||||
assertEquals(List.of(), monday(view).comfort());
|
||||
assertEquals(List.of(new Interval("12:00", "13:00")), view.weekdays().get(0).room().comfort());
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HeatPumpView;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HistoryView;
|
||||
import home.backend.domain.Period;
|
||||
import home.backend.domain.PiSensorView;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
@@ -71,14 +72,39 @@ class ServicesTest {
|
||||
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpPeriodsComeFromTargetsAndSetpoints() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of("program", 1.0, "target_cwu_temp", 43.0, "setpoint_cwu_economy", 8.0,
|
||||
"setpoint_cwu_comfort", 43.0, "target_room_temp", 18.0, "setpoint_co_economy", 18.0,
|
||||
"setpoint_co_comfort", 19.0));
|
||||
|
||||
assertEquals(Period.COMFORT, view.periods().cwu());
|
||||
assertEquals(Period.ECONOMY, view.periods().room());
|
||||
assertNull(view.periods().programText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpOtherProgramOverridesBothPeriodsAndIsNamed() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of("program", 2.0, "target_cwu_temp", 43.0, "setpoint_cwu_economy", 8.0,
|
||||
"setpoint_cwu_comfort", 43.0));
|
||||
|
||||
assertEquals(Period.OVERRIDE, view.periods().cwu());
|
||||
assertEquals(Period.OVERRIDE, view.periods().room());
|
||||
assertEquals("Party", view.periods().programText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpWithoutProgramHasUnknownPeriods() throws IOException {
|
||||
assertEquals(Period.UNKNOWN, heatPump(Map.of("target_cwu_temp", 43.0)).periods().cwu());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpMapsOutputsAlarmAndTime() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0, "output_heater", 100.0,
|
||||
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0,
|
||||
"output_cwu", 100.0, "output_co", 0.0, "alarm", 4.0, "setpoint_cwu_economy", 8.0));
|
||||
|
||||
assertEquals(READ, view.time());
|
||||
assertEquals(66, view.compressorPercent());
|
||||
assertTrue(view.heaterOn());
|
||||
assertTrue(view.alarm());
|
||||
assertTrue(view.pumps().cwu());
|
||||
assertFalse(view.pumps().co());
|
||||
@@ -279,6 +305,14 @@ class ServicesTest {
|
||||
assertEquals(new BatteryView.Slot("21:00", "01:00", 8000, 60, false), schedule.get(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryScheduleIsEmptyWhenASlotStartIsMissing() throws IOException {
|
||||
Map<String, Double> fields = settings(1);
|
||||
fields.remove("prog3_start_hhmm");
|
||||
|
||||
assertTrue(battery(deyeSettings(fields, READ)).schedule().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryModeFollowsTimeOfUse() throws IOException {
|
||||
BatteryView backup = battery(deyeSettings(settings(0), READ));
|
||||
@@ -394,7 +428,8 @@ class ServicesTest {
|
||||
assertEquals(2000.0, view.pvPowerW());
|
||||
assertEquals(4.4, view.today().pvKwh());
|
||||
assertNull(view.gridPowerW());
|
||||
assertNull(view.today().loadKwh());
|
||||
assertNull(view.housePowerW());
|
||||
assertNull(view.today().houseKwh());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -407,6 +442,82 @@ class ServicesTest {
|
||||
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
|
||||
}
|
||||
|
||||
private static Map<String, Double> deye(double pv1, double pv2, double battery, double grid) {
|
||||
return Map.of("pv1_power_w", pv1, "pv2_power_w", pv2, "battery_power_w", battery, "grid_power_w", grid,
|
||||
"load_power_w", 0.0);
|
||||
}
|
||||
|
||||
private static EnergyView house(Map<String, Double> deye, double froniusW, double meterW) throws IOException {
|
||||
DeviceSnapshot meter = device("house-meter", "electricity", "PRAD2",
|
||||
Map.of("total_power_active", meterW));
|
||||
return energy(device("deye", "home", "deye", deye),
|
||||
device("fronius", "home", "fronius", Map.of("pv_power_w", froniusW)), meter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void housePowerBalancesPvBatteryAndMeterWhileExporting() throws IOException {
|
||||
EnergyView view = house(deye(2638.0, 3000.0, -15.0, -5768.0), 444.0, -5762.0);
|
||||
|
||||
assertEquals(305.0, view.housePowerW());
|
||||
assertEquals(0.0, view.backupOutputW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void housePowerAtNightIsImportPlusBatteryDischarge() throws IOException {
|
||||
EnergyView view = house(deye(0.0, 0.0, 800.0, 1000.0), 0.0, 1000.0);
|
||||
|
||||
assertEquals(1800.0, view.housePowerW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void housePowerIsNeverNegative() throws IOException {
|
||||
assertEquals(0.0, house(deye(0.0, 0.0, -500.0, 100.0), 0.0, 100.0).housePowerW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void housePowerFallsBackToDeyeGridWhenTheMeterIsStale() throws IOException {
|
||||
Instant old = NOW.minusSeconds(3600);
|
||||
var meter = new DeviceSnapshot("house-meter", true, old, null, List.of(new Measurement("electricity",
|
||||
"PRAD2", Map.of("total_power_active", 9999.0), old)));
|
||||
|
||||
EnergyView view = energy(device("deye", "home", "deye", deye(1000.0, 0.0, 0.0, -600.0)),
|
||||
device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0)), meter);
|
||||
|
||||
assertEquals(600.0, view.housePowerW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void housePowerIsNullWhenASourceIsMissing() throws IOException {
|
||||
Map<String, Double> deye = deye(1000.0, 0.0, 0.0, -600.0);
|
||||
assertNull(energy(device("deye", "home", "deye", deye)).housePowerW());
|
||||
assertNull(energy(device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0))).housePowerW());
|
||||
var noGrid = Map.of("pv1_power_w", 1.0, "pv2_power_w", 1.0, "battery_power_w", 1.0);
|
||||
assertNull(energy(device("deye", "home", "deye", noGrid),
|
||||
device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0))).housePowerW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void houseTodayIsPvPlusNetGridPlusNetBattery() throws IOException {
|
||||
var deye = Map.of("pv_today_kwh", 29.9, "grid_bought_today_kwh", 4.4, "grid_sold_today_kwh", 16.8,
|
||||
"battery_discharge_today_kwh", 5.0, "battery_charge_today_kwh", 2.1);
|
||||
|
||||
EnergyView view = energy(device("deye", "home", "deye", deye),
|
||||
device("fronius", "home", "fronius", Map.of("energy_day_wh", 4400.0)));
|
||||
|
||||
assertEquals(24.8, view.today().houseKwh());
|
||||
}
|
||||
|
||||
@Test
|
||||
void houseTodayIsNullWhenACounterIsMissing() throws IOException {
|
||||
var deye = Map.of("pv_today_kwh", 29.9, "grid_bought_today_kwh", 4.4, "grid_sold_today_kwh", 16.8,
|
||||
"battery_discharge_today_kwh", 5.0);
|
||||
|
||||
EnergyView view = energy(device("deye", "home", "deye", deye),
|
||||
device("fronius", "home", "fronius", Map.of("energy_day_wh", 4400.0)));
|
||||
|
||||
assertNull(view.today().houseKwh());
|
||||
}
|
||||
|
||||
private static EnergyDetails details(DeviceSnapshot... devices) throws IOException {
|
||||
return new EnergyDetailsService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
@@ -498,8 +609,9 @@ class ServicesTest {
|
||||
assertNull(view.gridPowerW());
|
||||
assertNull(view.batteryPowerW());
|
||||
assertNull(view.batterySocPct());
|
||||
assertNull(view.loadPowerW());
|
||||
assertNull(view.today().loadKwh());
|
||||
assertNull(view.housePowerW());
|
||||
assertNull(view.backupOutputW());
|
||||
assertNull(view.today().houseKwh());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -127,15 +127,38 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void boostPresetSendsOneTimedCommand() throws Exception {
|
||||
var result = service.applyPreset("cwu-now", null, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("cwu-now", null, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.APPLIED, result.status());
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chosenHoursReplaceTheTimedPresetsOwn() throws Exception {
|
||||
service.applyPreset("cwu-now", null, 4, Map.of("cwu_economy", 47), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_economy", 47, Duration.ofHours(4), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hoursOutsideOneToFourAreRefusedBeforeAnythingIsSent() {
|
||||
for (int hours : List.of(0, 5, -1, 72)) {
|
||||
assertThrows(SetpointService.InvalidHoursException.class,
|
||||
() -> service.applyPreset("cwu-now", null, hours, Map.of(), "anna"));
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldPresetHasNoHoursToChoose() {
|
||||
assertThrows(SetpointService.InvalidHoursException.class,
|
||||
() -> service.applyPreset("away", RETURN, 2, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(2, result.results().size());
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
@@ -144,7 +167,7 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
|
||||
service.applyPreset("away", RETURN, Map.of("co_comfort", 16), "anna");
|
||||
service.applyPreset("away", RETURN, null, Map.of("co_comfort", 16), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
|
||||
@@ -152,7 +175,7 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
|
||||
service.applyPreset("cwu-now", null, Map.of("cwu_economy", 40), "anna");
|
||||
service.applyPreset("cwu-now", null, null, Map.of("cwu_economy", 40), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
@@ -160,7 +183,7 @@ class SetpointServiceTest {
|
||||
@Test
|
||||
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("cwu_economy", 6), "anna"));
|
||||
() -> service.applyPreset("away", RETURN, null, Map.of("cwu_economy", 6), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@@ -168,14 +191,14 @@ class SetpointServiceTest {
|
||||
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
|
||||
for (int value : List.of(4, 26, 0)) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("co_comfort", value), "anna"));
|
||||
() -> service.applyPreset("away", RETURN, null, Map.of("co_comfort", value), "anna"));
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldValueThatDoesNotLowerTheCurrentOneWritesNothing() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of("co_comfort", 19), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of("co_comfort", 19), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -184,8 +207,8 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, null, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, null, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@@ -194,7 +217,7 @@ class SetpointServiceTest {
|
||||
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 19, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -204,7 +227,7 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void unknownPresetIsEmpty() throws Exception {
|
||||
assertTrue(service.applyPreset("nope", null, Map.of(), "anna").isEmpty());
|
||||
assertTrue(service.applyPreset("nope", null, null, Map.of(), "anna").isEmpty());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@@ -213,7 +236,7 @@ class SetpointServiceTest {
|
||||
var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null),
|
||||
new Change("cwu_economy", 60, 2.0)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, Map.of(), "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -225,7 +248,7 @@ class SetpointServiceTest {
|
||||
void presetWithUnknownSetpointWritesNothing() throws Exception {
|
||||
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, Map.of(), "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertTrue(sent.isEmpty());
|
||||
@@ -239,7 +262,7 @@ class SetpointServiceTest {
|
||||
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
|
||||
script.add(Status.FAILED);
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -250,7 +273,7 @@ class SetpointServiceTest {
|
||||
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -260,7 +283,7 @@ class SetpointServiceTest {
|
||||
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
|
||||
script.add(Status.REJECTED);
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -270,7 +293,7 @@ class SetpointServiceTest {
|
||||
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
|
||||
@@ -280,7 +303,7 @@ class SetpointServiceTest {
|
||||
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
|
||||
script.add(new IOException("tunnel dropped"));
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -292,7 +315,7 @@ class SetpointServiceTest {
|
||||
|
||||
assertThrows(IOException.class, service::current);
|
||||
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, Map.of(), "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, null, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.StatsView;
|
||||
import home.backend.domain.StatsView.Day;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class StatsServiceTest {
|
||||
|
||||
/** Monday 2026-10-26 11:00 in Warsaw; the week before it holds the change to winter time. */
|
||||
private static final Instant NOW = Instant.parse("2026-10-26T10:00:00Z");
|
||||
|
||||
private Instant askedFrom;
|
||||
private Instant askedTo;
|
||||
|
||||
private StatsView stats(String range, Instant now, HeatPumpSample... samples) throws IOException {
|
||||
HeatPumpSamples repository = (from, to) -> {
|
||||
askedFrom = from;
|
||||
askedTo = to;
|
||||
return List.of(samples);
|
||||
};
|
||||
return new StatsService(repository, Clock.fixed(now, ZoneOffset.UTC)).stats(range);
|
||||
}
|
||||
|
||||
private static HeatPumpSample compressor(String time, double stage) {
|
||||
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, null, null, Periods.UNKNOWN);
|
||||
}
|
||||
|
||||
private static Day day(StatsView view, String date) {
|
||||
return view.days().stream().filter(d -> d.date().equals(LocalDate.parse(date))).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksForWholeLocalDaysUpToNow() throws IOException {
|
||||
stats("7d", NOW);
|
||||
|
||||
assertEquals(Instant.parse("2026-10-19T22:00:00Z"), askedFrom);
|
||||
assertEquals(NOW, askedTo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsOneDayPerLocalDateOldestFirst() throws IOException {
|
||||
StatsView view = stats("30d", NOW);
|
||||
|
||||
assertEquals(30, view.days().size());
|
||||
assertEquals(LocalDate.parse("2026-09-27"), view.days().getFirst().date());
|
||||
assertEquals(LocalDate.parse("2026-10-26"), view.days().getLast().date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDataGivesEmptyDaysAndZeroTotals() throws IOException {
|
||||
StatsView view = stats("7d", NOW);
|
||||
|
||||
assertEquals(7, view.days().size());
|
||||
for (Day d : view.days()) {
|
||||
assertNull(d.compressorHours());
|
||||
assertNull(d.compressorStarts());
|
||||
assertNull(d.avgOutdoorTemp());
|
||||
}
|
||||
assertEquals(0.0, view.totals().compressorHours());
|
||||
assertEquals(0, view.totals().compressorStarts());
|
||||
assertNull(view.totals().avgOutdoorTemp());
|
||||
assertNull(view.totals().minCwuTemp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGapIsCappedAtTenMinutesPerPoll() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-23T10:00:00Z", 100),
|
||||
compressor("2026-10-23T10:05:00Z", 100),
|
||||
compressor("2026-10-23T11:00:00Z", 0));
|
||||
|
||||
assertEquals(0.25, day(view, "2026-10-23").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theLastPollHoldsUntilNowAtMostTenMinutes() throws IOException {
|
||||
StatsView recent = stats("7d", NOW, compressor("2026-10-26T09:58:00Z", 50));
|
||||
StatsView old = stats("7d", NOW, compressor("2026-10-26T08:00:00Z", 50));
|
||||
|
||||
assertEquals(0.03, day(recent, "2026-10-26").compressorHours());
|
||||
assertEquals(0.17, day(old, "2026-10-26").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsOnlyTransitionsFromOffToOn() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-23T10:00:00Z", 100),
|
||||
compressor("2026-10-23T10:05:00Z", 100),
|
||||
compressor("2026-10-23T10:10:00Z", 0),
|
||||
compressor("2026-10-23T10:15:00Z", 0),
|
||||
compressor("2026-10-23T10:20:00Z", 50),
|
||||
compressor("2026-10-23T10:25:00Z", 100),
|
||||
compressor("2026-10-23T10:30:00Z", 0),
|
||||
compressor("2026-10-23T10:35:00Z", 100));
|
||||
|
||||
assertEquals(2, day(view, "2026-10-23").compressorStarts());
|
||||
assertEquals(2, view.totals().compressorStarts());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aStartAtMidnightBelongsToTheNewDay() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-24T21:55:00Z", 0),
|
||||
compressor("2026-10-24T22:00:00Z", 100));
|
||||
|
||||
assertEquals(0, day(view, "2026-10-24").compressorStarts());
|
||||
assertEquals(1, day(view, "2026-10-25").compressorStarts());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitsAStretchAtLocalMidnightInSummerTime() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-24T21:58:00Z", 100),
|
||||
compressor("2026-10-24T22:03:00Z", 0));
|
||||
|
||||
assertEquals(0.03, day(view, "2026-10-24").compressorHours());
|
||||
assertEquals(0.05, day(view, "2026-10-25").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitsAStretchAtLocalMidnightInWinterTime() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-25T22:58:00Z", 100),
|
||||
compressor("2026-10-25T23:03:00Z", 0));
|
||||
|
||||
assertEquals(0.03, day(view, "2026-10-25").compressorHours());
|
||||
assertEquals(0.05, day(view, "2026-10-26").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theDayOfTheClockChangeLastsTwentyFiveHours() throws IOException {
|
||||
List<HeatPumpSample> samples = new ArrayList<>();
|
||||
Instant at = Instant.parse("2026-10-24T22:00:00Z");
|
||||
while (at.isBefore(Instant.parse("2026-10-25T23:00:00Z"))) {
|
||||
samples.add(compressor(at.toString(), 100));
|
||||
at = at.plusSeconds(300);
|
||||
}
|
||||
|
||||
StatsView view = stats("7d", NOW, samples.toArray(HeatPumpSample[]::new));
|
||||
|
||||
assertEquals(25.0, day(view, "2026-10-25").compressorHours());
|
||||
assertNull(day(view, "2026-10-26").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sumsEachOutputSeparately() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 100.0, 0.0, 10.0, 40.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 12.0, 38.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, null, null, Periods.UNKNOWN));
|
||||
|
||||
Day d = day(view, "2026-10-23");
|
||||
assertEquals(0.17, d.compressorHours());
|
||||
assertEquals(0.08, d.cwuHours());
|
||||
assertEquals(0.08, d.coHours());
|
||||
assertEquals(11.0, d.avgOutdoorTemp());
|
||||
assertEquals(38.0, d.minCwuTemp());
|
||||
assertEquals(40.0, d.maxCwuTemp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void totalsCoverTheWholeRangeWithTheTemperatureOfAllPolls() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
new HeatPumpSample(Instant.parse("2026-10-22T10:00:00Z"), 100.0, 0.0, 0.0, 10.0, 30.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 10.0, 31.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 4.0, 45.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 4.0, 44.0, Periods.UNKNOWN));
|
||||
|
||||
assertEquals(0.17, view.totals().compressorHours());
|
||||
assertEquals(7.0, view.totals().avgOutdoorTemp());
|
||||
assertEquals(30.0, view.totals().minCwuTemp());
|
||||
assertEquals(45.0, view.totals().maxCwuTemp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingFieldCountsAsOffAndUnknownStateIsNoStart() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), null, null, null, null, null, Periods.UNKNOWN),
|
||||
compressor("2026-10-23T10:05:00Z", 100));
|
||||
|
||||
assertEquals(0, day(view, "2026-10-23").compressorStarts());
|
||||
assertNull(day(view, "2026-10-23").avgOutdoorTemp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnknownRange() {
|
||||
assertThrows(IllegalArgumentException.class, () -> stats("24h", NOW));
|
||||
assertThrows(IllegalArgumentException.class, () -> stats(null, NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesRepositoryFailure() {
|
||||
StatsService service = new StatsService((from, to) -> {
|
||||
throw new IOException("influx down");
|
||||
}, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
|
||||
assertThrows(IOException.class, () -> service.stats("7d"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.Forecast.DailyForecast;
|
||||
import home.backend.domain.WeatherView;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class WeatherServiceTest {
|
||||
|
||||
private static final Instant START = Instant.parse("2026-10-24T10:00:00Z");
|
||||
private static final LocalDate TODAY = LocalDate.parse("2026-10-24");
|
||||
|
||||
private Instant now = START;
|
||||
private final Clock clock = new Clock() {
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone) {
|
||||
return Clock.fixed(now, zone);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return now;
|
||||
}
|
||||
};
|
||||
|
||||
private final AtomicInteger forecastCalls = new AtomicInteger();
|
||||
private final AtomicInteger warningCalls = new AtomicInteger();
|
||||
private boolean forecastDown;
|
||||
private boolean warningsDown;
|
||||
private boolean influxDown;
|
||||
private int measuredDays = 5;
|
||||
private LocalDate today = TODAY;
|
||||
private List<Warning> warned = List.of(warning(START.plus(Duration.ofHours(8))));
|
||||
|
||||
private final WeatherService service = new WeatherService(
|
||||
() -> {
|
||||
forecastCalls.incrementAndGet();
|
||||
if (forecastDown) {
|
||||
throw new IOException("open-meteo down");
|
||||
}
|
||||
return forecast();
|
||||
},
|
||||
() -> {
|
||||
warningCalls.incrementAndGet();
|
||||
if (warningsDown) {
|
||||
throw new IOException("imgw down");
|
||||
}
|
||||
return warned;
|
||||
},
|
||||
(from, to) -> {
|
||||
if (influxDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
assertEquals(today.minusDays(14), from);
|
||||
assertEquals(today, to);
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
for (int i = 1; i <= measuredDays; i++) {
|
||||
measured.put(today.minusDays(i), 15.0);
|
||||
}
|
||||
return measured;
|
||||
},
|
||||
clock);
|
||||
|
||||
private Forecast forecast() {
|
||||
Map<LocalDate, DailyForecast> days = new TreeMap<>();
|
||||
for (int i = 1; i <= 14; i++) {
|
||||
days.put(today.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0));
|
||||
}
|
||||
days.put(today, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0));
|
||||
days.put(today.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null));
|
||||
days.put(today.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0));
|
||||
return new Forecast(8.5, days);
|
||||
}
|
||||
|
||||
private static Warning warning(Instant until) {
|
||||
return new Warning("Burza", 2, OffsetDateTime.ofInstant(START, ZoneOffset.UTC),
|
||||
OffsetDateTime.ofInstant(until, ZoneOffset.UTC), "text");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shapesThreeDaysAndEstimatesPvFromTheFactor() throws IOException {
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertEquals(START, view.time());
|
||||
assertEquals(8.5, view.now().temperature());
|
||||
assertEquals(3.0, view.pvFactor(), 1e-9);
|
||||
assertEquals(TODAY, view.today().date());
|
||||
assertEquals("Bezchmurnie", view.today().description());
|
||||
assertEquals(12.34, view.today().tMax());
|
||||
assertEquals(1.5, view.today().sunshineHours());
|
||||
assertEquals(18.0, view.today().pvForecastKwh());
|
||||
assertEquals(TODAY.plusDays(2), view.dayAfter().date());
|
||||
assertEquals("Deszcz", view.tomorrow().description());
|
||||
assertEquals(5.5, view.tomorrow().precipitationMm());
|
||||
assertNull(view.tomorrow().pvForecastKwh());
|
||||
assertEquals(3.0, view.dayAfter().pvForecastKwh());
|
||||
assertEquals("Brak danych", view.dayAfter().description());
|
||||
assertFalse(view.stale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesTheWarsawDateNearMidnight() throws IOException {
|
||||
now = Instant.parse("2026-10-23T22:30:00Z");
|
||||
|
||||
assertEquals(TODAY, service.current().today().date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aForecastFetchedBeforeLocalMidnightIsNotUsedAfterIt() throws IOException {
|
||||
now = Instant.parse("2026-10-24T21:50:00Z");
|
||||
service.current();
|
||||
|
||||
now = Instant.parse("2026-10-24T22:05:00Z");
|
||||
today = TODAY.plusDays(1);
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertEquals(2, forecastCalls.get());
|
||||
assertEquals(today, view.today().date());
|
||||
assertEquals(today.plusDays(2), view.dayAfter().date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedRefreshServesTheOlderWarningsWithoutAskingAgainFor60Seconds() throws IOException {
|
||||
service.current();
|
||||
warningsDown = true;
|
||||
now = START.plus(Duration.ofMinutes(11));
|
||||
service.current();
|
||||
assertEquals(2, warningCalls.get());
|
||||
|
||||
now = now.plusSeconds(59);
|
||||
assertTrue(service.current().stale());
|
||||
assertEquals(2, warningCalls.get());
|
||||
|
||||
now = now.plusSeconds(1);
|
||||
service.current();
|
||||
assertEquals(3, warningCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnyForecastItFailsFastDuringTheBackOff() {
|
||||
forecastDown = true;
|
||||
assertThrows(IOException.class, service::current);
|
||||
|
||||
now = START.plusSeconds(30);
|
||||
assertThrows(IOException.class, service::current);
|
||||
|
||||
assertEquals(1, forecastCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fewerThanFiveMeasuredDaysGiveNoEstimate() throws IOException {
|
||||
measuredDays = 4;
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertNull(view.pvFactor());
|
||||
assertNull(view.today().pvForecastKwh());
|
||||
assertEquals(12.34, view.today().tMax());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnreachableInfluxGivesNoEstimateButStillTheWeather() throws IOException {
|
||||
influxDown = true;
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertNull(view.pvFactor());
|
||||
assertEquals(0, view.today().weatherCode());
|
||||
assertFalse(view.stale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheForecastFor30MinutesAndWarningsFor10() throws IOException {
|
||||
service.current();
|
||||
now = START.plus(Duration.ofMinutes(9));
|
||||
service.current();
|
||||
assertEquals(1, warningCalls.get());
|
||||
|
||||
now = START.plus(Duration.ofMinutes(10));
|
||||
service.current();
|
||||
assertEquals(2, warningCalls.get());
|
||||
assertEquals(1, forecastCalls.get());
|
||||
|
||||
now = START.plus(Duration.ofMinutes(30));
|
||||
service.current();
|
||||
assertEquals(2, forecastCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheOlderForecastMarkedStaleWhenTheRefreshFails() throws IOException {
|
||||
service.current();
|
||||
forecastDown = true;
|
||||
now = START.plus(Duration.ofMinutes(31));
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertEquals(8.5, view.now().temperature());
|
||||
assertEquals(3.0, view.pvFactor(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheOlderWarningsMarkedStaleWhenTheRefreshFails() throws IOException {
|
||||
service.current();
|
||||
warningsDown = true;
|
||||
now = START.plus(Duration.ofMinutes(11));
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertEquals(1, view.warnings().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnyForecastItFails() {
|
||||
forecastDown = true;
|
||||
|
||||
assertThrows(IOException.class, service::current);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnyWarningsItStillAnswersMarkedStale() throws IOException {
|
||||
warningsDown = true;
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertTrue(view.warnings().isEmpty());
|
||||
assertTrue(view.stale());
|
||||
assertEquals(12.34, view.today().tMax());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropsWarningsThatHaveEnded() throws IOException {
|
||||
warned = List.of(warning(START.plus(Duration.ofMinutes(5))), warning(START.plus(Duration.ofHours(8))));
|
||||
|
||||
assertEquals(2, service.current().warnings().size());
|
||||
|
||||
now = START.plus(Duration.ofMinutes(6));
|
||||
assertEquals(1, service.current().warnings().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aForecastWithoutTodayFails() {
|
||||
var short_ = new WeatherService(() -> new Forecast(null, Map.of()), List::of, (from, to) -> Map.of(), clock);
|
||||
|
||||
assertThrows(IOException.class, short_::current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,84 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class WeatherDomainTest {
|
||||
|
||||
private static final LocalDate DAY = LocalDate.parse("2026-10-10");
|
||||
|
||||
@Test
|
||||
void describesWmoCodesInPolish() {
|
||||
assertEquals("Bezchmurnie", WeatherCodes.describe(0));
|
||||
assertEquals("Pochmurno", WeatherCodes.describe(3));
|
||||
assertEquals("Mgła", WeatherCodes.describe(45));
|
||||
assertEquals("Mżawka", WeatherCodes.describe(53));
|
||||
assertEquals("Deszcz", WeatherCodes.describe(63));
|
||||
assertEquals("Śnieg", WeatherCodes.describe(73));
|
||||
assertEquals("Przelotny deszcz", WeatherCodes.describe(81));
|
||||
assertEquals("Burza", WeatherCodes.describe(95));
|
||||
assertEquals("Burza z gradem", WeatherCodes.describe(99));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownOrMissingCodeIsNoData() {
|
||||
assertEquals("Brak danych", WeatherCodes.describe(42));
|
||||
assertEquals("Brak danych", WeatherCodes.describe(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void calibratesAsTotalMeasuredOverTotalRadiation() {
|
||||
var measured = new HashMap<LocalDate, Double>();
|
||||
var radiation = new HashMap<LocalDate, Double>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
measured.put(DAY.plusDays(i), 10.0 + i);
|
||||
radiation.put(DAY.plusDays(i), 5.0 + i);
|
||||
}
|
||||
|
||||
// 60 kWh over 35 MJ/m², not the mean of the daily ratios.
|
||||
assertEquals(60.0 / 35.0, PvCalibration.factor(measured, radiation), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void daysMissingEitherValueDoNotCount() {
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
Map<LocalDate, Double> radiation = new HashMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
measured.put(DAY.plusDays(i), 10.0);
|
||||
radiation.put(DAY.plusDays(i), 5.0);
|
||||
}
|
||||
measured.put(DAY.plusDays(9), 1000.0);
|
||||
radiation.put(DAY.plusDays(8), 1000.0);
|
||||
|
||||
assertEquals(2.0, PvCalibration.factor(measured, radiation), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fewerThanFiveDaysGiveNoFactor() {
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
Map<LocalDate, Double> radiation = new HashMap<>();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
measured.put(DAY.plusDays(i), 10.0);
|
||||
radiation.put(DAY.plusDays(i), 5.0);
|
||||
}
|
||||
|
||||
assertNull(PvCalibration.factor(measured, radiation));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noRadiationGivesNoFactor() {
|
||||
Map<LocalDate, Double> days = new HashMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
days.put(DAY.plusDays(i), 0.0);
|
||||
}
|
||||
|
||||
assertNull(PvCalibration.factor(Map.of(), days));
|
||||
assertNull(PvCalibration.factor(days, days));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entries": [
|
||||
{ "time": "2026-10-24T09:30:00Z", "source": "battery", "what": "Rezerwa baterii", "oldValue": 50, "newValue": 60,
|
||||
"unit": "%", "who": "mariusz", "status": "APPLIED" },
|
||||
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "Ciepła woda – oszczędnie", "oldValue": 45, "newValue": 8,
|
||||
"unit": "°C", "who": "automatycznie (przywrócenie)", "status": "APPLIED" },
|
||||
{ "time": "2026-10-23T18:00:00Z", "source": "battery", "what": "Przedział 3: ładowanie z sieci", "oldValue": 0,
|
||||
"newValue": 1, "unit": "0/1", "who": "mariusz", "status": "FAILED" },
|
||||
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "Ogrzewanie – komfort", "oldValue": null, "newValue": 22,
|
||||
"unit": "°C", "who": "mariusz", "status": "REJECTED" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "local": true, "oidc": true, "setup": false }
|
||||
@@ -0,0 +1 @@
|
||||
{ "user": "tata", "method": "local" }
|
||||
@@ -13,7 +13,7 @@
|
||||
{ "title": "Sieć", "items": [
|
||||
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Odbiór domu", "items": [
|
||||
{ "title": "Wyjście zapasowe falownika", "items": [
|
||||
{ "label": "Moc razem", "value": 570.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Panele PV", "items": [
|
||||
@@ -22,7 +22,7 @@
|
||||
] },
|
||||
{ "title": "Dziś", "items": [
|
||||
{ "label": "Produkcja PV", "value": 29.9, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Zużycie domu", "value": 24.2, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Wyjście zapasowe", "value": 24.2, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Bateria naładowana", "value": 0.5, "unit": "kWh", "digits": 1 },
|
||||
@@ -49,6 +49,7 @@
|
||||
"id": "house-meter", "name": "Licznik domu", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Moc czynna", "items": [
|
||||
{ "label": "Razem", "value": 389.0, "unit": "W", "digits": 0 },
|
||||
{ "label": "Moc L1", "value": 56.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Napięcie", "items": [
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"gridPowerW": null,
|
||||
"batteryPowerW": null,
|
||||
"batterySocPct": null,
|
||||
"loadPowerW": null,
|
||||
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "loadKwh": null }
|
||||
"housePowerW": null,
|
||||
"backupOutputW": null,
|
||||
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "houseKwh": null }
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"gridPowerW": 645.0,
|
||||
"batteryPowerW": -24.0,
|
||||
"batterySocPct": 99.0,
|
||||
"loadPowerW": 570.0,
|
||||
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "loadKwh": 24.2 }
|
||||
"housePowerW": 364.78,
|
||||
"backupOutputW": 570.0,
|
||||
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "houseKwh": 28.4 }
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
"stale": true,
|
||||
"state": null,
|
||||
"compressorPercent": null,
|
||||
"heaterOn": false,
|
||||
"alarm": false,
|
||||
"alarmCode": null,
|
||||
"alarmText": null,
|
||||
@@ -13,5 +12,6 @@
|
||||
"groundIn": null, "groundOut": null, "pumpOutlet": null, "outdoorAvg": null
|
||||
},
|
||||
"targets": { "room": null, "co": null, "cwu": null },
|
||||
"setpoints": { "coEconomy": null, "coComfort": null, "cwuEconomy": null, "cwuComfort": null }
|
||||
"setpoints": { "coEconomy": null, "coComfort": null, "cwuEconomy": null, "cwuComfort": null },
|
||||
"periods": { "cwu": "unknown", "room": "unknown", "program": null, "programText": null }
|
||||
}
|
||||
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user