Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b288c98106 | ||
|
|
e5e13cedb5 | ||
|
|
a594512f33 | ||
|
|
a5ba9e8756 | ||
|
|
619c3ee990 | ||
|
|
8111405326 | ||
|
|
965dfae644 | ||
|
|
2da7246fe4 | ||
|
|
a4dc88cbbc | ||
|
|
a01c507a6e | ||
|
|
a8ef638fcd | ||
|
|
8396372943 | ||
|
|
25f76ea084 | ||
|
|
8146296061 | ||
|
|
c21094b4f4 | ||
|
|
3b6d073b1f | ||
|
|
63c552dfd7 | ||
|
|
de19424a7b | ||
|
|
03b4ee1a48 | ||
|
|
355dd76555 | ||
|
|
b6997560d2 | ||
|
|
09042c6338 | ||
|
|
f9fbff4eea | ||
|
|
7e50d1203b | ||
|
|
934330e157 | ||
|
|
2e273f05c2 | ||
|
|
9a48442220 | ||
|
|
b3038aeef7 | ||
|
|
9e997e43f3 | ||
|
|
8ae6cea536 | ||
|
|
604ef4e6d9 | ||
|
|
b0735471b4 | ||
|
|
6f50f5b7b9 | ||
|
|
bad67b9538 | ||
|
|
ce00d0e81b | ||
|
|
0eebba072e | ||
|
|
ea83255b91 | ||
|
|
ba8631b3c2 | ||
|
|
6cae491b1c | ||
|
|
83333a19bf | ||
|
|
0072017966 | ||
|
|
35b3f8e1e0 | ||
|
|
7a17bbbcdb | ||
|
|
f0c974c82a | ||
|
|
d557f98df6 | ||
|
|
9f33f5a94c | ||
|
|
798f98b69d | ||
|
|
e65cb5837f | ||
|
|
0b3197ac55 | ||
|
|
5513c09c51 | ||
|
|
697b68ec39 | ||
|
|
645bbc4b88 | ||
|
|
2d7899ebc8 | ||
|
|
c34a9da0d7 | ||
|
|
a8f6384e04 | ||
|
|
3c68dc3621 |
No files matched your search
@@ -24,7 +24,8 @@ Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/
|
|||||||
then `git push origin master` and delete the branch locally and on origin. Confirm CI is
|
then `git push origin master` and delete the branch locally and on origin. Confirm CI is
|
||||||
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
|
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
|
||||||
6. Release and update only with the user's explicit approval for this one:
|
6. Release and update only with the user's explicit approval for this one:
|
||||||
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) tags master;
|
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) waits for green
|
||||||
|
CI on master's head (no local check run), then tags master;
|
||||||
wait until the Release run under `…/actions` is green (it publishes the images). Then on the
|
wait until the Release run under `…/actions` is green (it publishes the images). Then on the
|
||||||
Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`,
|
Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`,
|
||||||
watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and
|
watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# One job: check.sh builds the three parts in parallel itself. The runner runs one job at a time
|
||||||
|
# and its actions cache server is unreachable from job containers (ETIMEDOUT), so separate jobs
|
||||||
|
# or setup-java/setup-node caches would only add waiting.
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|||||||
@@ -13,8 +13,20 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
images:
|
images:
|
||||||
name: Images
|
name: Image (${{ matrix.part }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into
|
||||||
|
# the final stage, so the arm64 image needs no emulation.
|
||||||
|
- part: gateway
|
||||||
|
platforms: linux/arm64,linux/amd64
|
||||||
|
- part: backend
|
||||||
|
platforms: linux/amd64
|
||||||
|
- part: frontend
|
||||||
|
platforms: linux/amd64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -31,26 +43,14 @@ jobs:
|
|||||||
username: ${{ gitea.repository_owner }}
|
username: ${{ gitea.repository_owner }}
|
||||||
# The owner's token with package write access (the secret homelab-upgrade uses too).
|
# The owner's token with package write access (the secret homelab-upgrade uses too).
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into the
|
# The registry cache keeps the Maven and npm layers between releases.
|
||||||
# final stage, so the arm64 image needs no emulation.
|
- name: Build and push
|
||||||
- name: Gateway
|
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: gateway
|
context: ${{ matrix.part }}
|
||||||
platforms: linux/arm64,linux/amd64
|
build-args: VERSION=${{ gitea.ref_name }}
|
||||||
|
platforms: ${{ matrix.platforms }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
|
tags: ${{ env.IMAGES }}-${{ matrix.part }}:${{ gitea.ref_name }},${{ env.IMAGES }}-${{ matrix.part }}:latest
|
||||||
- name: Backend
|
cache-from: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache
|
||||||
uses: docker/build-push-action@v6
|
cache-to: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache,mode=max
|
||||||
with:
|
|
||||||
context: backend
|
|
||||||
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
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: true
|
|
||||||
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
|
|
||||||
+6
-3
@@ -8,12 +8,15 @@ COPY src src
|
|||||||
RUN mvn -B -q package -Dmaven.test.skip=true
|
RUN mvn -B -q package -Dmaven.test.skip=true
|
||||||
|
|
||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
# The release tag, shown in /api/health; the release workflow passes it.
|
||||||
|
ARG VERSION=dev
|
||||||
|
ENV VERSION=$VERSION
|
||||||
USER 10001:10001
|
USER 10001:10001
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body),
|
# /api/live needs no session and does not look at the gateway, so it only tells whether the backend
|
||||||
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine.
|
# itself serves requests. busybox wget ships with alpine.
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||||
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/health" || exit 1
|
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/live" || exit 1
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
+123
-6
@@ -3,10 +3,47 @@
|
|||||||
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
|
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
|
||||||
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
|
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
|
||||||
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
|
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
|
||||||
|
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
|
||||||
|
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
|
||||||
|
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
|
||||||
|
the three values are null (`pi-sensor-stale.json`).
|
||||||
|
|
||||||
|
`GET /api/stats?range=7d|30d` (example `src/test/resources/api/stats.json`; 400 otherwise, 502 when InfluxDB is down) is the heat pump per local
|
||||||
|
day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` with `date`, `compressorHours`, `compressorStarts`, `cwuHours`, `coHours`,
|
||||||
|
`heaterHours`, `avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
|
||||||
|
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
|
||||||
|
|
||||||
|
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
|
||||||
|
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
|
||||||
|
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
|
||||||
|
`mode` (`touEnabled` with a Polish sentence), `schedule` (six slots, each from its start to the next one, the last wraps past midnight),
|
||||||
|
`limits`, and `capacityKwh` = capacity in Ah x a nominal 51.2 V, since the inverter reports Ah only. When stale they are null (`schedule` empty).
|
||||||
|
|
||||||
|
`/api/battery` also has `reserve` (`pct`, `floorPct`: the backup reserve of the app and the lowest the user may set) and `editable`:
|
||||||
|
true when the gateway answers its `/battery/reserve`, i.e. it has battery commands on and `GATEWAY_TOKEN` is right; else `reserve` is null
|
||||||
|
and `editable` false. `POST /api/battery/schedule` (examples `battery-schedule-request.json`, `battery-failed.json`) takes
|
||||||
|
`{"enabled"?, "slots": [{"slot": 1-6, "from"?: "HH:MM", "socPct"?, "gridCharge"?}]}` and `POST /api/battery/reserve` takes `{"pct": 60}`.
|
||||||
|
Same Origin, header and JSON rules as the other writes; 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
|
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||||
without the two URLs.
|
without the two URLs.
|
||||||
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`.
|
Setpoint changes go only to the gateway; the signed-in user is sent as `X-Requested-By`.
|
||||||
|
|
||||||
| Variable | Default | Meaning |
|
| Variable | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -16,15 +53,95 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us
|
|||||||
| `GATEWAY_TIMEOUT` | `5` | seconds |
|
| `GATEWAY_TIMEOUT` | `5` | seconds |
|
||||||
| `INFLUX_TIMEOUT` | `10` | 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 |
|
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
|
||||||
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start |
|
| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast |
|
||||||
|
| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count |
|
||||||
|
| `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
|
```bash
|
||||||
mvn -f backend/pom.xml clean package
|
mvn -f backend/pom.xml clean package
|
||||||
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
|
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example AUTH_DISABLED=true \
|
||||||
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
|
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
|
||||||
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
|
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
|
||||||
```
|
```
|
||||||
|
|
||||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
|
## Sign-in (ADR-014)
|
||||||
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
|
|
||||||
rules (ADR-009).
|
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
|
||||||
|
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, 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
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
TZ: Europe/Warsaw
|
TZ: Europe/Warsaw
|
||||||
|
# Sign-in (README): the accounts directory, writable by uid 10001 (chown 10001:10001 auth); the app rewrites users.json in it. In .env, for example:
|
||||||
|
# USERS_FILE=/config/auth/users.json SESSION_SECRET=<openssl rand -base64 48>
|
||||||
|
# OIDC_ISSUER=https://login.mariuszpacek.pl/realms/home OIDC_CLIENT_ID=home-pump
|
||||||
|
# OIDC_CLIENT_SECRET=<from Keycloak> OIDC_REQUIRED_ROLE=family
|
||||||
|
# OIDC_REDIRECT_URI=https://pompa.mariuszpacek.pl/api/auth/oidc/callback
|
||||||
|
volumes:
|
||||||
|
- ./auth:/config/auth
|
||||||
networks: [home-pump]
|
networks: [home-pump]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,41 +1,83 @@
|
|||||||
package home.backend;
|
package home.backend;
|
||||||
|
|
||||||
|
import home.backend.adapters.auth.Authentication;
|
||||||
import home.backend.adapters.config.PresetFile;
|
import home.backend.adapters.config.PresetFile;
|
||||||
import home.backend.adapters.gateway.GatewayHttpClient;
|
import home.backend.adapters.gateway.GatewayHttpClient;
|
||||||
import home.backend.adapters.gateway.GatewayHttpCommands;
|
import home.backend.adapters.gateway.GatewayHttpCommands;
|
||||||
import home.backend.adapters.http.ApiServer;
|
import home.backend.adapters.http.ApiServer;
|
||||||
|
import home.backend.adapters.influx.InfluxAuditRepository;
|
||||||
import home.backend.adapters.influx.InfluxHistoryRepository;
|
import home.backend.adapters.influx.InfluxHistoryRepository;
|
||||||
|
import home.backend.adapters.influx.InfluxPvEnergy;
|
||||||
import home.backend.adapters.influx.InfluxSettings;
|
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.EnergyService;
|
||||||
import home.backend.application.HealthService;
|
import home.backend.application.HealthService;
|
||||||
import home.backend.application.HeatPumpService;
|
import home.backend.application.HeatPumpService;
|
||||||
import home.backend.application.HistoryService;
|
import home.backend.application.HistoryService;
|
||||||
|
import home.backend.application.StatsService;
|
||||||
|
import home.backend.application.LeaseRenewer;
|
||||||
|
import home.backend.application.PiSensorService;
|
||||||
import home.backend.application.SetpointService;
|
import home.backend.application.SetpointService;
|
||||||
|
import home.backend.application.WeatherService;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
import java.time.Clock;
|
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. */
|
/** Wires the adapters and services together; the only place that knows all of them. */
|
||||||
final class Backend {
|
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() {
|
private Backend() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binds the port but does not start serving.
|
* Binds the port but does not start serving.
|
||||||
*
|
*
|
||||||
* @throws IllegalArgumentException when PRESETS_FILE is unusable
|
* @throws IllegalArgumentException when PRESETS_FILE or USERS_FILE is unusable
|
||||||
*/
|
*/
|
||||||
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
|
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
|
||||||
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
|
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
|
||||||
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
|
var influxSettings = new InfluxSettings(settings.influxUrl(), settings.influxTimeout());
|
||||||
var commands = new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(),
|
var influx = new InfluxHistoryRepository(influxSettings);
|
||||||
settings.gatewayTimeout());
|
var commands = commands(settings);
|
||||||
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
|
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
|
||||||
return new ApiServer(settings.port(),
|
return new ApiServer(settings.port(),
|
||||||
new HeatPumpService(gateway, clock),
|
new HeatPumpService(gateway, clock),
|
||||||
new EnergyService(gateway, clock),
|
new EnergyService(gateway, clock),
|
||||||
new HealthService(gateway, clock),
|
new EnergyDetailsService(gateway, clock),
|
||||||
new HistoryService(influx, clock), setpoints);
|
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 AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(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). */
|
||||||
|
static LeaseRenewer leaseRenewer(Settings settings) {
|
||||||
|
return new LeaseRenewer(commands(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewayHttpCommands commands(Settings settings) {
|
||||||
|
return new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(), settings.gatewayTimeout());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@ package home.backend;
|
|||||||
|
|
||||||
import home.backend.adapters.http.ApiServer;
|
import home.backend.adapters.http.ApiServer;
|
||||||
|
|
||||||
|
import home.backend.adapters.auth.PasswordHashCli;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
|
||||||
@@ -11,6 +13,9 @@ public final class Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void main(String[] args) throws IOException {
|
public static void main(String[] args) throws IOException {
|
||||||
|
if (args.length == 1 && args[0].equals("hash-password")) {
|
||||||
|
System.exit(PasswordHashCli.run(System.console(), System.in, System.out, System.err));
|
||||||
|
}
|
||||||
Settings settings;
|
Settings settings;
|
||||||
ApiServer server;
|
ApiServer server;
|
||||||
try {
|
try {
|
||||||
@@ -21,10 +26,15 @@ public final class Main {
|
|||||||
System.exit(2);
|
System.exit(2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
settings.auth().warnings().forEach(w -> System.err.println("home-pump-backend: WARNING: " + w));
|
||||||
|
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||||
if (settings.gatewayToken() == null) {
|
if (settings.gatewayToken() == null) {
|
||||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||||
|
} else {
|
||||||
|
var renewer = Backend.leaseRenewer(settings);
|
||||||
|
Runtime.getRuntime().addShutdownHook(new Thread(renewer::close));
|
||||||
|
renewer.start();
|
||||||
}
|
}
|
||||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
|
||||||
server.start();
|
server.start();
|
||||||
System.out.println("home-pump-backend listening on port " + server.port());
|
System.out.println("home-pump-backend listening on port " + server.port());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package home.backend;
|
package home.backend;
|
||||||
|
|
||||||
|
import home.backend.adapters.auth.AuthSettings;
|
||||||
|
import home.backend.adapters.auth.OidcSettings;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -8,7 +12,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
/** Twelve-factor settings: everything comes from environment variables. */
|
/** Twelve-factor settings: everything comes from environment variables. */
|
||||||
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
|
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
|
||||||
String gatewayToken, Path presetsFile) {
|
String gatewayToken, Path presetsFile, AuthSettings auth) {
|
||||||
|
|
||||||
public static Settings fromEnvironment(Map<String, String> env) {
|
public static Settings fromEnvironment(Map<String, String> env) {
|
||||||
return new Settings(
|
return new Settings(
|
||||||
@@ -18,7 +22,44 @@ public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gateway
|
|||||||
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
|
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
|
||||||
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
|
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
|
||||||
optional(env, "GATEWAY_TOKEN"),
|
optional(env, "GATEWAY_TOKEN"),
|
||||||
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null));
|
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null),
|
||||||
|
auth(env));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int MIN_SECRET_BYTES = 32;
|
||||||
|
|
||||||
|
/** Refuses to start open by accident: sign-in is configured, or AUTH_DISABLED=true says it is meant. */
|
||||||
|
private static AuthSettings auth(Map<String, String> env) {
|
||||||
|
Path usersFile = Optional.ofNullable(optional(env, "USERS_FILE")).map(Path::of).orElse(null);
|
||||||
|
OidcSettings oidc = optional(env, "OIDC_ISSUER") == null ? null : oidc(env);
|
||||||
|
String secret = optional(env, "SESSION_SECRET");
|
||||||
|
boolean disabled = "true".equals(optional(env, "AUTH_DISABLED"));
|
||||||
|
int days = (int) number(env, "SESSION_DAYS", 30, 1, 365);
|
||||||
|
int hops = (int) number(env, "TRUSTED_PROXY_HOPS", 1, 1, 10);
|
||||||
|
boolean configured = usersFile != null || oidc != null;
|
||||||
|
if (disabled && configured) {
|
||||||
|
throw new IllegalArgumentException("AUTH_DISABLED=true contradicts USERS_FILE and OIDC_ISSUER: unset one");
|
||||||
|
}
|
||||||
|
if (!disabled && !configured) {
|
||||||
|
throw new IllegalArgumentException("no sign-in configured: set USERS_FILE and/or OIDC_ISSUER, "
|
||||||
|
+ "or AUTH_DISABLED=true for development only");
|
||||||
|
}
|
||||||
|
if (configured && (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < MIN_SECRET_BYTES)) {
|
||||||
|
throw new IllegalArgumentException("SESSION_SECRET is required and must be at least " + MIN_SECRET_BYTES
|
||||||
|
+ " bytes, e.g. from: openssl rand -base64 48");
|
||||||
|
}
|
||||||
|
return new AuthSettings(usersFile, oidc, secret, days, disabled, hops);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OidcSettings oidc(Map<String, String> env) {
|
||||||
|
for (String name : new String[] {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI"}) {
|
||||||
|
if (optional(env, name) == null) {
|
||||||
|
throw new IllegalArgumentException(name + " is required when OIDC_ISSUER is set");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new OidcSettings(URI.create(optional(env, "OIDC_ISSUER")), optional(env, "OIDC_CLIENT_ID"),
|
||||||
|
optional(env, "OIDC_CLIENT_SECRET"), URI.create(optional(env, "OIDC_REDIRECT_URI")),
|
||||||
|
optional(env, "OIDC_REQUIRED_ROLE"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Null when unset or blank. */
|
/** Null when unset or blank. */
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How users sign in. At least one of usersFile and oidc, or disabled; the secret signs the cookies.
|
||||||
|
* proxyHops is how many proxies append to X-Forwarded-For before the backend (Traefik: 1).
|
||||||
|
*/
|
||||||
|
public record AuthSettings(Path usersFile, OidcSettings oidc, String sessionSecret, int sessionDays,
|
||||||
|
boolean disabled, int proxyHops) {
|
||||||
|
|
||||||
|
/** Things worth saying at startup. */
|
||||||
|
public List<String> warnings() {
|
||||||
|
if (oidc != null && oidc.requiredRole() == null) {
|
||||||
|
return List.of("OIDC_REQUIRED_ROLE is not set: every user of the Keycloak realm can sign in");
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.Semaphore;
|
||||||
|
|
||||||
|
/** Who may use the API: local accounts and OIDC, independent of each other, both ending in a session cookie. */
|
||||||
|
public final class Authentication {
|
||||||
|
|
||||||
|
public static final String LOCAL = "local";
|
||||||
|
|
||||||
|
public enum Status { OK, INVALID, THROTTLED }
|
||||||
|
|
||||||
|
public record Login(Status status, long retryAfterSeconds) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int MAX_FAILURES = 5;
|
||||||
|
private static final Duration THROTTLE_WINDOW = Duration.ofMinutes(15);
|
||||||
|
/** Password hashing is slow on purpose; a flood must not queue up behind it. */
|
||||||
|
private static final int PARALLEL_HASHES = 4;
|
||||||
|
private static final int MIN_PASSWORD = 10;
|
||||||
|
private static final long BUSY_RETRY_SECONDS = 1;
|
||||||
|
|
||||||
|
private final Sessions sessions;
|
||||||
|
private final UserStore users;
|
||||||
|
private final LoginThrottle throttle;
|
||||||
|
private final OidcFlow oidc;
|
||||||
|
private final int proxyHops;
|
||||||
|
private final Semaphore hashing = new Semaphore(PARALLEL_HASHES);
|
||||||
|
|
||||||
|
private Authentication(Sessions sessions, UserStore users, LoginThrottle throttle, OidcFlow oidc, int proxyHops) {
|
||||||
|
this.sessions = sessions;
|
||||||
|
this.users = users;
|
||||||
|
this.throttle = throttle;
|
||||||
|
this.oidc = oidc;
|
||||||
|
this.proxyHops = proxyHops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws IllegalArgumentException when USERS_FILE is unusable */
|
||||||
|
public static Authentication create(AuthSettings settings, Clock clock) {
|
||||||
|
if (settings.disabled()) {
|
||||||
|
return new Authentication(null, null, null, null, settings.proxyHops());
|
||||||
|
}
|
||||||
|
var tokens = new SignedTokens(settings.sessionSecret().getBytes(StandardCharsets.UTF_8), clock);
|
||||||
|
return new Authentication(new Sessions(tokens, clock, Duration.ofDays(settings.sessionDays())),
|
||||||
|
settings.usersFile() == null ? null : UserStore.load(settings.usersFile()),
|
||||||
|
new LoginThrottle(clock, MAX_FAILURES, THROTTLE_WINDOW),
|
||||||
|
settings.oidc() == null ? null : new OidcFlow(settings.oidc(), tokens, clock),
|
||||||
|
settings.proxyHops());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For development and tests: every request is the user "phone". */
|
||||||
|
public static Authentication disabled() {
|
||||||
|
return new Authentication(null, null, null, null, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean localEnabled() {
|
||||||
|
return users != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean oidcEnabled() {
|
||||||
|
return oidc != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OidcFlow oidc() {
|
||||||
|
return oidc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int proxyHops() {
|
||||||
|
return proxyHops;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Session> session(String cookieHeader) {
|
||||||
|
if (sessions == null) {
|
||||||
|
return Optional.of(Session.OPEN);
|
||||||
|
}
|
||||||
|
// A removed account loses its sessions at once; OIDC sessions end with their expiry.
|
||||||
|
return sessions.read(cookieHeader).filter(s -> !LOCAL.equals(s.method()) || users != null && users.exists(s.user()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks the password unless the user or the address is blocked; a blocked one learns nothing about it. */
|
||||||
|
public Login login(String username, char[] password, String clientIp) {
|
||||||
|
String userKey = "user:" + username.toLowerCase(Locale.ROOT);
|
||||||
|
String ipKey = "ip:" + clientIp;
|
||||||
|
long wait = throttle.tryAcquire(userKey, ipKey);
|
||||||
|
if (wait > 0) {
|
||||||
|
return new Login(Status.THROTTLED, wait);
|
||||||
|
}
|
||||||
|
if (!hashing.tryAcquire()) {
|
||||||
|
throttle.release(userKey, ipKey);
|
||||||
|
return new Login(Status.THROTTLED, BUSY_RETRY_SECONDS);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (users.authenticate(username, password)) {
|
||||||
|
throttle.cleared(userKey);
|
||||||
|
throttle.release(ipKey);
|
||||||
|
return new Login(Status.OK, 0);
|
||||||
|
}
|
||||||
|
return new Login(Status.INVALID, 0);
|
||||||
|
} finally {
|
||||||
|
hashing.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean setupNeeded() {
|
||||||
|
return users != null && users.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean strongEnough(String password) {
|
||||||
|
return password.length() >= MIN_PASSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the first account, once: OK for the winner, INVALID (setup is done) for everyone after. */
|
||||||
|
public Login setup(String username, char[] password, String clientIp) {
|
||||||
|
String ipKey = "ip:" + clientIp;
|
||||||
|
long wait = throttle.tryAcquire(ipKey);
|
||||||
|
if (wait > 0) {
|
||||||
|
return new Login(Status.THROTTLED, wait);
|
||||||
|
}
|
||||||
|
if (!users.addFirst(username, PasswordHash.create(password))) {
|
||||||
|
return new Login(Status.INVALID, 0);
|
||||||
|
}
|
||||||
|
System.err.println("home-pump-backend: setup created the first account " + username);
|
||||||
|
throttle.release(ipKey);
|
||||||
|
return new Login(Status.OK, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public java.util.List<String> usernames() {
|
||||||
|
return users.usernames();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return false when the name is taken */
|
||||||
|
public boolean addUser(String by, String username, char[] password) {
|
||||||
|
boolean added = users.add(username, PasswordHash.create(password));
|
||||||
|
System.err.println("home-pump-backend: " + by + " added account " + username + (added ? "" : " (refused, exists)"));
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks the current password like a login does (throttled), then sets the new one. */
|
||||||
|
public Login changePassword(String username, char[] current, char[] next, String clientIp) {
|
||||||
|
Login login = login(username, current, clientIp);
|
||||||
|
if (login.status() == Status.OK) {
|
||||||
|
users.changePassword(username, PasswordHash.create(next));
|
||||||
|
System.err.println("home-pump-backend: " + username + " changed their password");
|
||||||
|
}
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserStore.Removal removeUser(String by, String username) {
|
||||||
|
var removal = users.remove(username);
|
||||||
|
System.err.println("home-pump-backend: " + by + " removed account " + username + ": " + removal);
|
||||||
|
return removal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Set-Cookie header value that signs the user in. */
|
||||||
|
public String sessionCookie(String user, String method) {
|
||||||
|
return sessions.setCookie(user, method);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String clearedSessionCookie() {
|
||||||
|
return sessions == null ? "" : sessions.clearCookie();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/** Unpadded base64url, as tokens and PKCE use it. */
|
||||||
|
final class Base64Url {
|
||||||
|
|
||||||
|
private Base64Url() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String encode(byte[] bytes) {
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] decode(String text) {
|
||||||
|
return Base64.getUrlDecoder().decode(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/** Reads the Cookie header and builds Set-Cookie headers with the flags every cookie here carries. */
|
||||||
|
final class Cookies {
|
||||||
|
|
||||||
|
private Cookies() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static Optional<String> read(String header, String name) {
|
||||||
|
if (header == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
for (String pair : header.split(";")) {
|
||||||
|
String[] kv = pair.trim().split("=", 2);
|
||||||
|
if (kv.length == 2 && kv[0].equals(name)) {
|
||||||
|
return Optional.of(kv[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String set(String name, String value, Duration maxAge, String path) {
|
||||||
|
return name + "=" + value + "; Max-Age=" + maxAge.toSeconds() + attributes(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String clear(String name, String path) {
|
||||||
|
return name + "=; Max-Age=0" + attributes(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String attributes(String path) {
|
||||||
|
return "; Path=" + path + "; HttpOnly; Secure; SameSite=Lax";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.Signature;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
|
||||||
|
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||||
|
|
||||||
|
/** Checks an ID token with the JDK alone: RS256 signature, issuer, audience, times, nonce and the required role. */
|
||||||
|
final class IdTokenVerifier {
|
||||||
|
|
||||||
|
/** The signing keys of the identity provider. */
|
||||||
|
interface Keys {
|
||||||
|
PublicKey key(String kid) throws OidcException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
private static final Duration SKEW = Duration.ofSeconds(60);
|
||||||
|
|
||||||
|
private final OidcSettings settings;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
IdTokenVerifier(OidcSettings settings, Clock clock) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the user name of the token */
|
||||||
|
String verify(String idToken, String nonce, Keys keys) throws OidcException {
|
||||||
|
String[] parts = idToken.split("\\.", -1);
|
||||||
|
if (parts.length != 3) {
|
||||||
|
throw new OidcException(FAILED, "the ID token is not a JWT");
|
||||||
|
}
|
||||||
|
JsonNode header = json(parts[0]);
|
||||||
|
if (!"RS256".equals(header.path("alg").asText())) {
|
||||||
|
throw new OidcException(FAILED, "the ID token is not signed with RS256");
|
||||||
|
}
|
||||||
|
verifySignature(parts[0] + "." + parts[1], parts[2], keys.key(header.path("kid").asText()));
|
||||||
|
JsonNode claims = json(parts[1]);
|
||||||
|
checkClaims(claims, nonce);
|
||||||
|
checkRole(claims);
|
||||||
|
return userName(claims);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkClaims(JsonNode claims, String nonce) throws OidcException {
|
||||||
|
long now = clock.instant().getEpochSecond();
|
||||||
|
if (!settings.issuerBase().equals(claims.path("iss").asText("").replaceAll("/+$", ""))) {
|
||||||
|
throw new OidcException(FAILED, "wrong issuer");
|
||||||
|
}
|
||||||
|
if (!audiences(claims).contains(settings.clientId())) {
|
||||||
|
throw new OidcException(FAILED, "the token is for another client");
|
||||||
|
}
|
||||||
|
if (audiences(claims).size() > 1 && !settings.clientId().equals(claims.path("azp").asText())) {
|
||||||
|
throw new OidcException(FAILED, "wrong authorized party");
|
||||||
|
}
|
||||||
|
if (!claims.path("exp").canConvertToLong() || claims.get("exp").asLong() + SKEW.toSeconds() <= now) {
|
||||||
|
throw new OidcException(FAILED, "the token has expired");
|
||||||
|
}
|
||||||
|
if (!claims.path("iat").canConvertToLong() || claims.get("iat").asLong() - SKEW.toSeconds() > now) {
|
||||||
|
throw new OidcException(FAILED, "the token is issued in the future");
|
||||||
|
}
|
||||||
|
boolean sameNonce = MessageDigest.isEqual(nonce.getBytes(StandardCharsets.UTF_8),
|
||||||
|
claims.path("nonce").asText("").getBytes(StandardCharsets.UTF_8));
|
||||||
|
if (!sameNonce) {
|
||||||
|
throw new OidcException(FAILED, "wrong nonce");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A realm role or a role of this client, as Keycloak puts them in {@code realm_access} and {@code resource_access}. */
|
||||||
|
private void checkRole(JsonNode claims) throws OidcException {
|
||||||
|
String role = settings.requiredRole();
|
||||||
|
if (role == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean has = contains(claims.path("realm_access").path("roles"), role)
|
||||||
|
|| contains(claims.path("resource_access").path(settings.clientId()).path("roles"), role);
|
||||||
|
if (!has) {
|
||||||
|
throw new OidcException(DENIED, "the user lacks the role " + role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String userName(JsonNode claims) throws OidcException {
|
||||||
|
for (String claim : new String[] {"preferred_username", "email", "sub"}) {
|
||||||
|
String name = claims.path(claim).asText("");
|
||||||
|
if (Session.isValidUser(name)) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new OidcException(FAILED, "the token has no usable user name");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> audiences(JsonNode claims) {
|
||||||
|
JsonNode aud = claims.path("aud");
|
||||||
|
var all = new ArrayList<String>();
|
||||||
|
if (aud.isArray()) {
|
||||||
|
aud.forEach(a -> all.add(a.asText()));
|
||||||
|
} else if (aud.isTextual()) {
|
||||||
|
all.add(aud.asText());
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean contains(JsonNode array, String value) {
|
||||||
|
for (JsonNode item : array) {
|
||||||
|
if (value.equals(item.asText())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void verifySignature(String signedPart, String signature, PublicKey key) throws OidcException {
|
||||||
|
try {
|
||||||
|
Signature rsa = Signature.getInstance("SHA256withRSA");
|
||||||
|
rsa.initVerify(key);
|
||||||
|
rsa.update(signedPart.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
if (!rsa.verify(Base64Url.decode(signature))) {
|
||||||
|
throw new OidcException(FAILED, "the ID token signature is wrong");
|
||||||
|
}
|
||||||
|
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||||
|
throw new OidcException(FAILED, "the ID token signature cannot be checked", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode json(String part) throws OidcException {
|
||||||
|
try {
|
||||||
|
return JSON.readTree(Base64Url.decode(part));
|
||||||
|
} catch (IOException | IllegalArgumentException e) {
|
||||||
|
throw new OidcException(FAILED, "the ID token is malformed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Blocks a key (a username or a client address) after too many failures within a window. */
|
||||||
|
public final class LoginThrottle {
|
||||||
|
|
||||||
|
private final Clock clock;
|
||||||
|
private final int maxFailures;
|
||||||
|
private final Duration window;
|
||||||
|
/** The newest failures of each key, at most {@code maxFailures}, oldest first. */
|
||||||
|
private final Map<String, ArrayDeque<Instant>> failures = new HashMap<>();
|
||||||
|
|
||||||
|
public LoginThrottle(Clock clock, int maxFailures, Duration window) {
|
||||||
|
this.clock = clock;
|
||||||
|
this.maxFailures = maxFailures;
|
||||||
|
this.window = window;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts an attempt against every key before the password is checked, so parallel guesses cannot
|
||||||
|
* outrun the limit; {@link #release} takes it back.
|
||||||
|
*
|
||||||
|
* @return 0 when the attempt may go on, else the seconds until the most blocked key may try again
|
||||||
|
*/
|
||||||
|
public synchronized long tryAcquire(String... keys) {
|
||||||
|
Instant now = clock.instant();
|
||||||
|
long wait = 0;
|
||||||
|
for (String key : keys) {
|
||||||
|
var times = failures.get(key);
|
||||||
|
if (times != null && times.size() >= maxFailures && times.peekFirst().plus(window).isAfter(now)) {
|
||||||
|
wait = Math.max(wait, Duration.between(now, times.peekFirst().plus(window)).toSeconds() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wait > 0) {
|
||||||
|
return wait;
|
||||||
|
}
|
||||||
|
failures.values().removeIf(times -> !times.peekLast().plus(window).isAfter(now));
|
||||||
|
for (String key : keys) {
|
||||||
|
var times = failures.computeIfAbsent(key, k -> new ArrayDeque<>());
|
||||||
|
times.addLast(now);
|
||||||
|
if (times.size() > maxFailures) {
|
||||||
|
times.removeFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes back the newest attempt of each key. */
|
||||||
|
public synchronized void release(String... keys) {
|
||||||
|
for (String key : keys) {
|
||||||
|
var times = failures.get(key);
|
||||||
|
if (times != null) {
|
||||||
|
times.pollLast();
|
||||||
|
if (times.isEmpty()) {
|
||||||
|
failures.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void cleared(String key) {
|
||||||
|
failures.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
/** Why an OIDC sign-in did not happen; the code is what the login screen is told. */
|
||||||
|
public final class OidcException extends Exception {
|
||||||
|
|
||||||
|
public enum Reason {
|
||||||
|
/** Keycloak did not answer. */
|
||||||
|
UNAVAILABLE("oidc_unavailable"),
|
||||||
|
/** The user said no, or lacks the required role. */
|
||||||
|
DENIED("oidc_denied"),
|
||||||
|
/** Anything else: bad state, a token that does not verify, a refused code. */
|
||||||
|
FAILED("oidc_failed");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
Reason(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Reason reason;
|
||||||
|
|
||||||
|
public OidcException(Reason reason, String message) {
|
||||||
|
super(message);
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OidcException(Reason reason, String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Reason reason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
|
||||||
|
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorization code flow with PKCE (S256), state and nonce. What must survive the trip to the identity
|
||||||
|
* provider travels in a short-lived signed cookie, so the backend keeps no state.
|
||||||
|
*/
|
||||||
|
public final class OidcFlow {
|
||||||
|
|
||||||
|
/** Where to send the browser, and the cookie to set on that response. */
|
||||||
|
public record Start(URI location, String setCookie) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String COOKIE = "hp_oidc";
|
||||||
|
private static final String PURPOSE = "oidc-login";
|
||||||
|
private static final String PATH = "/api/auth/oidc";
|
||||||
|
private static final Duration LOGIN_TIME = Duration.ofMinutes(10);
|
||||||
|
private static final Duration TIMEOUT = Duration.ofSeconds(5);
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private final OidcSettings settings;
|
||||||
|
private final OidcProvider provider;
|
||||||
|
private final IdTokenVerifier verifier;
|
||||||
|
private final SignedTokens tokens;
|
||||||
|
|
||||||
|
OidcFlow(OidcSettings settings, SignedTokens tokens, Clock clock) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.provider = new OidcProvider(settings, TIMEOUT, clock);
|
||||||
|
this.verifier = new IdTokenVerifier(settings, clock);
|
||||||
|
this.tokens = tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws OidcException UNAVAILABLE when the identity provider does not answer */
|
||||||
|
public Start start() throws OidcException {
|
||||||
|
String state = random();
|
||||||
|
String nonce = random();
|
||||||
|
String verifierText = random();
|
||||||
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
|
query.put("response_type", "code");
|
||||||
|
query.put("client_id", settings.clientId());
|
||||||
|
query.put("redirect_uri", settings.redirectUri().toString());
|
||||||
|
query.put("scope", "openid profile");
|
||||||
|
query.put("state", state);
|
||||||
|
query.put("nonce", nonce);
|
||||||
|
query.put("code_challenge", challenge(verifierText));
|
||||||
|
query.put("code_challenge_method", "S256");
|
||||||
|
URI endpoint = provider.endpoints().authorization();
|
||||||
|
String separator = endpoint.getRawQuery() == null ? "?" : "&";
|
||||||
|
String cookie = Cookies.set(COOKIE, tokens.issue(PURPOSE, LOGIN_TIME,
|
||||||
|
Map.of("state", state, "nonce", nonce, "verifier", verifierText)), LOGIN_TIME, PATH);
|
||||||
|
return new Start(URI.create(endpoint + separator + query.entrySet().stream()
|
||||||
|
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
|
||||||
|
.collect(Collectors.joining("&"))), cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the signed-in user's name */
|
||||||
|
public String callback(Map<String, String> query, String cookieHeader) throws OidcException {
|
||||||
|
Map<String, String> login = Cookies.read(cookieHeader, COOKIE).flatMap(t -> tokens.read(PURPOSE, t))
|
||||||
|
.orElseThrow(() -> new OidcException(FAILED, "no login in progress, or it took too long"));
|
||||||
|
boolean sameState = MessageDigest.isEqual(login.get("state").getBytes(StandardCharsets.UTF_8),
|
||||||
|
query.getOrDefault("state", "").getBytes(StandardCharsets.UTF_8));
|
||||||
|
if (!sameState) {
|
||||||
|
throw new OidcException(FAILED, "wrong state");
|
||||||
|
}
|
||||||
|
if ("access_denied".equals(query.get("error"))) {
|
||||||
|
throw new OidcException(DENIED, "the user denied access");
|
||||||
|
}
|
||||||
|
String code = query.get("code");
|
||||||
|
if (code == null || code.isEmpty()) {
|
||||||
|
throw new OidcException(FAILED, "the identity provider answered with " + printable(query.get("error")));
|
||||||
|
}
|
||||||
|
return verifier.verify(provider.exchange(code, login.get("verifier")), login.get("nonce"), provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String clearCookie() {
|
||||||
|
return Cookies.clear(COOKIE, PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The provider's text goes into the log: letters, digits and a few separators only, and short. */
|
||||||
|
static String printable(String text) {
|
||||||
|
String safe = text == null ? "nothing" : text.replaceAll("[^A-Za-z0-9_. -]", "?");
|
||||||
|
return safe.length() > 40 ? safe.substring(0, 40) + "..." : safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String random() {
|
||||||
|
byte[] bytes = new byte[32];
|
||||||
|
RANDOM.nextBytes(bytes);
|
||||||
|
return Base64Url.encode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String challenge(String verifierText) {
|
||||||
|
try {
|
||||||
|
return Base64Url.encode(MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(verifierText.getBytes(StandardCharsets.US_ASCII)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 is part of every JDK", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.spec.RSAPublicKeySpec;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||||
|
import static home.backend.adapters.auth.OidcException.Reason.UNAVAILABLE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity provider over HTTP: discovery, signing keys and the code exchange. Everything is fetched on first
|
||||||
|
* use and kept, so an identity provider that is down never delays the start and never affects local login.
|
||||||
|
*/
|
||||||
|
final class OidcProvider implements IdTokenVerifier.Keys {
|
||||||
|
|
||||||
|
record Endpoints(URI authorization, URI token, URI jwks) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
private static final Duration CACHE = Duration.ofHours(1);
|
||||||
|
/** An unknown key id may mean a rotation; look again, but not on every forged token. */
|
||||||
|
private static final Duration REFETCH_AFTER = Duration.ofMinutes(1);
|
||||||
|
|
||||||
|
private final OidcSettings settings;
|
||||||
|
private final HttpClient http;
|
||||||
|
private final Duration timeout;
|
||||||
|
private final Clock clock;
|
||||||
|
private Endpoints endpoints;
|
||||||
|
private Instant endpointsAt = Instant.MIN;
|
||||||
|
private Map<String, PublicKey> keys = Map.of();
|
||||||
|
private Instant keysAt = Instant.MIN;
|
||||||
|
|
||||||
|
OidcProvider(OidcSettings settings, Duration timeout, Clock clock) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.timeout = timeout;
|
||||||
|
this.clock = clock;
|
||||||
|
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The network calls run outside the lock, so a hanging identity provider holds up only its own callers. */
|
||||||
|
Endpoints endpoints() throws OidcException {
|
||||||
|
synchronized (this) {
|
||||||
|
if (endpoints != null && !endpointsAt.plus(CACHE).isBefore(clock.instant())) {
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String base = settings.issuerBase();
|
||||||
|
JsonNode doc = getJson(URI.create(base + "/.well-known/openid-configuration"));
|
||||||
|
if (!base.equals(doc.path("issuer").asText("").replaceAll("/+$", ""))) {
|
||||||
|
throw new OidcException(FAILED, "the discovery document names another issuer");
|
||||||
|
}
|
||||||
|
var fresh = new Endpoints(uri(doc, "authorization_endpoint"), uri(doc, "token_endpoint"), uri(doc, "jwks_uri"));
|
||||||
|
synchronized (this) {
|
||||||
|
endpoints = fresh;
|
||||||
|
endpointsAt = clock.instant();
|
||||||
|
}
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PublicKey key(String kid) throws OidcException {
|
||||||
|
synchronized (this) {
|
||||||
|
boolean stale = keysAt.plus(CACHE).isBefore(clock.instant());
|
||||||
|
boolean unknownAndRefetchable = !keys.containsKey(kid)
|
||||||
|
&& keysAt.plus(REFETCH_AFTER).isBefore(clock.instant());
|
||||||
|
if (!stale && !unknownAndRefetchable) {
|
||||||
|
return known(kid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String, PublicKey> fresh = parseKeys(getJson(endpoints().jwks()));
|
||||||
|
synchronized (this) {
|
||||||
|
keys = fresh;
|
||||||
|
keysAt = clock.instant();
|
||||||
|
return known(kid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PublicKey known(String kid) throws OidcException {
|
||||||
|
PublicKey key = keys.get(kid);
|
||||||
|
if (key == null) {
|
||||||
|
throw new OidcException(FAILED, "no signing key " + kid);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the ID token the identity provider issued for the code */
|
||||||
|
String exchange(String code, String verifier) throws OidcException {
|
||||||
|
var form = Map.of("grant_type", "authorization_code", "code", code,
|
||||||
|
"redirect_uri", settings.redirectUri().toString(), "code_verifier", verifier);
|
||||||
|
String credentials = encode(settings.clientId()) + ":" + encode(settings.clientSecret());
|
||||||
|
var request = HttpRequest.newBuilder(endpoints().token()).timeout(timeout)
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.header("Authorization", "Basic " + Base64.getEncoder()
|
||||||
|
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8)))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(form.entrySet().stream()
|
||||||
|
.map(e -> e.getKey() + "=" + encode(e.getValue())).collect(Collectors.joining("&"))))
|
||||||
|
.build();
|
||||||
|
JsonNode answer = send(request, "the token endpoint");
|
||||||
|
String idToken = answer.path("id_token").asText("");
|
||||||
|
if (idToken.isEmpty()) {
|
||||||
|
throw new OidcException(FAILED, "the token endpoint sent no id_token");
|
||||||
|
}
|
||||||
|
return idToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode getJson(URI uri) throws OidcException {
|
||||||
|
return send(HttpRequest.newBuilder(uri).timeout(timeout).GET().build(), uri.getPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode send(HttpRequest request, String what) throws OidcException {
|
||||||
|
try {
|
||||||
|
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() >= 500) {
|
||||||
|
throw new OidcException(UNAVAILABLE, what + " answered " + response.statusCode());
|
||||||
|
}
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new OidcException(FAILED, what + " answered " + response.statusCode());
|
||||||
|
}
|
||||||
|
return JSON.readTree(response.body());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new OidcException(UNAVAILABLE, what + " was interrupted", e);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new OidcException(UNAVAILABLE, what + " is unreachable: " + e, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A key that cannot be read is skipped, so one odd entry does not lock everyone out. */
|
||||||
|
private static Map<String, PublicKey> parseKeys(JsonNode jwks) {
|
||||||
|
Map<String, PublicKey> keys = new HashMap<>();
|
||||||
|
for (JsonNode jwk : jwks.path("keys")) {
|
||||||
|
boolean signingRsa = "RSA".equals(jwk.path("kty").asText()) && !"enc".equals(jwk.path("use").asText());
|
||||||
|
if (signingRsa && jwk.hasNonNull("kid")) {
|
||||||
|
try {
|
||||||
|
keys.put(jwk.get("kid").asText(), KeyFactory.getInstance("RSA").generatePublic(
|
||||||
|
new RSAPublicKeySpec(integer(jwk, "n"), integer(jwk, "e"))));
|
||||||
|
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||||
|
System.err.println("home-pump-backend: skipping unusable signing key " + jwk.get("kid").asText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigInteger integer(JsonNode jwk, String field) {
|
||||||
|
return new BigInteger(1, Base64Url.decode(jwk.path(field).asText()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static URI uri(JsonNode doc, String field) throws OidcException {
|
||||||
|
String value = doc.path(field).asText("");
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
throw new OidcException(FAILED, "the discovery document has no " + field);
|
||||||
|
}
|
||||||
|
return URI.create(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String encode(String value) {
|
||||||
|
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
/** requiredRole is null when any user of the realm may sign in. */
|
||||||
|
public record OidcSettings(URI issuer, String clientId, String clientSecret, URI redirectUri, String requiredRole) {
|
||||||
|
|
||||||
|
/** The issuer without a trailing slash, the form tokens and discovery are compared in. */
|
||||||
|
String issuerBase() {
|
||||||
|
return issuer.toString().replaceAll("/+$", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKeyFactory;
|
||||||
|
import javax.crypto.spec.PBEKeySpec;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/** Password hashes as {@code pbkdf2-sha256$<iterations>$<salt b64>$<hash b64>}: the cost travels with the hash. */
|
||||||
|
public final class PasswordHash {
|
||||||
|
|
||||||
|
private static final String SCHEME = "pbkdf2-sha256";
|
||||||
|
private static final int ITERATIONS = 600_000;
|
||||||
|
/** A file with a cheaper hash is refused, so a weak one cannot slip in. */
|
||||||
|
private static final int MIN_ITERATIONS = 310_000;
|
||||||
|
private static final int SALT_BYTES = 16;
|
||||||
|
private static final int HASH_BITS = 256;
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private record Parsed(int iterations, byte[] salt, byte[] hash) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private PasswordHash() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String create(char[] password) {
|
||||||
|
byte[] salt = new byte[SALT_BYTES];
|
||||||
|
RANDOM.nextBytes(salt);
|
||||||
|
return String.join("$", SCHEME, String.valueOf(ITERATIONS), Base64.getEncoder().encodeToString(salt),
|
||||||
|
Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS, HASH_BITS)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compares in constant time. */
|
||||||
|
public static boolean matches(String stored, char[] password) {
|
||||||
|
Parsed parsed = parse(stored);
|
||||||
|
byte[] actual = derive(password, parsed.salt(), parsed.iterations(), parsed.hash().length * 8);
|
||||||
|
return MessageDigest.isEqual(parsed.hash(), actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws IllegalArgumentException when the text is not a hash this class made or accepts */
|
||||||
|
public static void requireValid(String stored) {
|
||||||
|
parse(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Parsed parse(String stored) {
|
||||||
|
String[] parts = stored == null ? new String[0] : stored.split("\\$");
|
||||||
|
if (parts.length != 4 || !SCHEME.equals(parts[0])) {
|
||||||
|
throw new IllegalArgumentException("a password hash must look like " + SCHEME
|
||||||
|
+ "$<iterations>$<salt>$<hash>; create one with: hash-password");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int iterations = Integer.parseInt(parts[1]);
|
||||||
|
byte[] salt = Base64.getDecoder().decode(parts[2]);
|
||||||
|
byte[] hash = Base64.getDecoder().decode(parts[3]);
|
||||||
|
if (iterations < MIN_ITERATIONS || salt.length < SALT_BYTES || hash.length < HASH_BITS / 8) {
|
||||||
|
throw new IllegalArgumentException("password hash is too weak: at least " + MIN_ITERATIONS
|
||||||
|
+ " iterations, 16 bytes of salt, a 32 byte hash");
|
||||||
|
}
|
||||||
|
return new Parsed(iterations, salt, hash);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
// NumberFormatException and a bad base64 are both IllegalArgumentException
|
||||||
|
throw new IllegalArgumentException("password hash is malformed or too weak (at least " + MIN_ITERATIONS
|
||||||
|
+ " iterations, 16 bytes of salt, a 32 byte hash)", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] derive(char[] password, byte[] salt, int iterations, int bits) {
|
||||||
|
var spec = new PBEKeySpec(password, salt, iterations, bits);
|
||||||
|
try {
|
||||||
|
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
|
||||||
|
} catch (GeneralSecurityException e) {
|
||||||
|
throw new IllegalStateException("PBKDF2WithHmacSHA256 is part of every JDK", e);
|
||||||
|
} finally {
|
||||||
|
spec.clearPassword();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.Console;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/** {@code hash-password}: prints the hash for USERS_FILE; on a terminal the password is asked twice, unechoed. */
|
||||||
|
public final class PasswordHashCli {
|
||||||
|
|
||||||
|
private PasswordHashCli() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the exit code */
|
||||||
|
public static int run(Console console, InputStream stdin, PrintStream out, PrintStream err) throws IOException {
|
||||||
|
char[] password;
|
||||||
|
if (console != null) {
|
||||||
|
password = console.readPassword("Password: ");
|
||||||
|
char[] again = console.readPassword("Again: ");
|
||||||
|
if (password == null || !Arrays.equals(password, again)) {
|
||||||
|
err.println("The passwords differ.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String line = new BufferedReader(new InputStreamReader(stdin, StandardCharsets.UTF_8)).readLine();
|
||||||
|
password = line == null ? null : line.toCharArray();
|
||||||
|
}
|
||||||
|
if (password == null || password.length == 0) {
|
||||||
|
err.println("No password given.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
out.println(PasswordHash.create(password));
|
||||||
|
Arrays.fill(password, '\0');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** Who is signed in and how. {@code method} is {@code local}, {@code oidc} or {@code none} (AUTH_DISABLED). */
|
||||||
|
public record Session(String user, String method, Instant issuedAt) {
|
||||||
|
|
||||||
|
/** The name goes to the gateway's audit log as a header, so only plain characters are allowed. */
|
||||||
|
public static final String USER_PATTERN = "[\\w.@+-]{1,64}";
|
||||||
|
private static final Pattern USER = Pattern.compile(USER_PATTERN);
|
||||||
|
|
||||||
|
/** Every request is this one when authentication is switched off for development. */
|
||||||
|
static final Session OPEN = new Session("phone", "none", Instant.EPOCH);
|
||||||
|
|
||||||
|
public static boolean isValidUser(String name) {
|
||||||
|
return USER.matcher(name).matches();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/** The session cookie: a signed token that holds the user, the method and when it was issued. */
|
||||||
|
final class Sessions {
|
||||||
|
|
||||||
|
static final String COOKIE = "hp_session";
|
||||||
|
private static final String PURPOSE = "session";
|
||||||
|
|
||||||
|
private final SignedTokens tokens;
|
||||||
|
private final Clock clock;
|
||||||
|
private final Duration lifetime;
|
||||||
|
|
||||||
|
Sessions(SignedTokens tokens, Clock clock, Duration lifetime) {
|
||||||
|
this.tokens = tokens;
|
||||||
|
this.clock = clock;
|
||||||
|
this.lifetime = lifetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
String setCookie(String user, String method) {
|
||||||
|
String token = tokens.issue(PURPOSE, lifetime, Map.of("user", user, "method", method,
|
||||||
|
"iat", String.valueOf(clock.instant().getEpochSecond())));
|
||||||
|
return Cookies.set(COOKIE, token, lifetime, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
String clearCookie() {
|
||||||
|
return Cookies.clear(COOKIE, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<Session> read(String cookieHeader) {
|
||||||
|
return Cookies.read(cookieHeader, COOKIE).flatMap(token -> tokens.read(PURPOSE, token)).map(claims ->
|
||||||
|
new Session(claims.get("user"), claims.get("method"),
|
||||||
|
Instant.ofEpochSecond(Long.parseLong(claims.get("iat")))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/** Tamper-proof, expiring text for cookies: {@code base64url(json).base64url(HMAC-SHA256)}. */
|
||||||
|
final class SignedTokens {
|
||||||
|
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
private static final TypeReference<Map<String, String>> CLAIMS = new TypeReference<>() {
|
||||||
|
};
|
||||||
|
private static final String HMAC = "HmacSHA256";
|
||||||
|
|
||||||
|
private final SecretKeySpec key;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
SignedTokens(byte[] secret, Clock clock) {
|
||||||
|
this.key = new SecretKeySpec(secret, HMAC);
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The purpose keeps a token made for one use (a session) from passing as another (a login in progress). */
|
||||||
|
String issue(String purpose, Duration lifetime, Map<String, String> claims) {
|
||||||
|
var all = new HashMap<>(claims);
|
||||||
|
all.put("purpose", purpose);
|
||||||
|
all.put("exp", String.valueOf(clock.instant().plus(lifetime).getEpochSecond()));
|
||||||
|
try {
|
||||||
|
String payload = Base64Url.encode(JSON.writeValueAsBytes(all));
|
||||||
|
return payload + "." + Base64Url.encode(sign(payload));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<Map<String, String>> read(String purpose, String token) {
|
||||||
|
try {
|
||||||
|
String[] parts = token.split("\\.", -1);
|
||||||
|
if (parts.length != 2 || !MessageDigest.isEqual(sign(parts[0]), Base64Url.decode(parts[1]))) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Map<String, String> claims = JSON.readValue(Base64Url.decode(parts[0]), CLAIMS);
|
||||||
|
boolean valid = purpose.equals(claims.get("purpose"))
|
||||||
|
&& Long.parseLong(claims.get("exp")) > clock.instant().getEpochSecond();
|
||||||
|
return valid ? Optional.of(claims) : Optional.empty();
|
||||||
|
} catch (IOException | IllegalArgumentException e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] sign(String payload) {
|
||||||
|
try {
|
||||||
|
Mac mac = Mac.getInstance(HMAC);
|
||||||
|
mac.init(key);
|
||||||
|
return mac.doFinal(payload.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
} catch (GeneralSecurityException e) {
|
||||||
|
throw new IllegalStateException(HMAC + " is part of every JDK", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local accounts of USERS_FILE: {@code [{"username", "passwordHash"}]}. A missing file is an empty store
|
||||||
|
* (setup mode). Every change is written back atomically before it counts, under one lock.
|
||||||
|
*/
|
||||||
|
public final class UserStore {
|
||||||
|
|
||||||
|
public enum Removal { REMOVED, UNKNOWN, LAST }
|
||||||
|
|
||||||
|
private record Entry(String username, String passwordHash) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
/** What an unknown user is checked against, so that it costs as much as a wrong password. */
|
||||||
|
private static final String DECOY = PasswordHash.create(randomPassword());
|
||||||
|
|
||||||
|
private final Path file;
|
||||||
|
private Map<String, String> hashes;
|
||||||
|
|
||||||
|
UserStore(Path file, Map<String, String> hashes) {
|
||||||
|
this.file = file;
|
||||||
|
this.hashes = new LinkedHashMap<>(hashes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws IllegalArgumentException when the file exists but is unreadable or holds a bad account */
|
||||||
|
public static UserStore load(Path file) {
|
||||||
|
Map<String, String> hashes = new LinkedHashMap<>();
|
||||||
|
if (Files.exists(file)) {
|
||||||
|
List<Entry> entries;
|
||||||
|
try {
|
||||||
|
entries = JSON.readValue(file.toFile(), new TypeReference<>() {
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalArgumentException("USERS_FILE " + file + " is not usable: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
for (Entry entry : entries) {
|
||||||
|
if (entry.username() == null || !Session.isValidUser(entry.username())) {
|
||||||
|
throw new IllegalArgumentException("USERS_FILE: a username must match " + Session.USER_PATTERN);
|
||||||
|
}
|
||||||
|
PasswordHash.requireValid(entry.passwordHash());
|
||||||
|
if (hashes.put(entry.username(), entry.passwordHash()) != null) {
|
||||||
|
throw new IllegalArgumentException("USERS_FILE: duplicate username " + entry.username());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new UserStore(file, hashes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean isEmpty() {
|
||||||
|
return hashes.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean exists(String username) {
|
||||||
|
return hashes.containsKey(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized List<String> usernames() {
|
||||||
|
return List.copyOf(hashes.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean authenticate(String username, char[] password) {
|
||||||
|
String stored;
|
||||||
|
synchronized (this) {
|
||||||
|
stored = hashes.get(username);
|
||||||
|
}
|
||||||
|
boolean matches = PasswordHash.matches(stored == null ? DECOY : stored, password);
|
||||||
|
return stored != null && matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first account; false when there already is one, so only one setup can win. */
|
||||||
|
public synchronized boolean addFirst(String username, String hash) {
|
||||||
|
return hashes.isEmpty() && put(username, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return false when the name is taken */
|
||||||
|
public synchronized boolean add(String username, String hash) {
|
||||||
|
return !hashes.containsKey(username) && put(username, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return false when there is no such account */
|
||||||
|
public synchronized boolean changePassword(String username, String hash) {
|
||||||
|
return hashes.containsKey(username) && put(username, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized Removal remove(String username) {
|
||||||
|
if (!hashes.containsKey(username)) {
|
||||||
|
return Removal.UNKNOWN;
|
||||||
|
}
|
||||||
|
if (hashes.size() == 1) {
|
||||||
|
return Removal.LAST;
|
||||||
|
}
|
||||||
|
var next = new LinkedHashMap<>(hashes);
|
||||||
|
next.remove(username);
|
||||||
|
write(next);
|
||||||
|
hashes = next;
|
||||||
|
return Removal.REMOVED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean put(String username, String hash) {
|
||||||
|
var next = new LinkedHashMap<>(hashes);
|
||||||
|
next.put(username, hash);
|
||||||
|
write(next);
|
||||||
|
hashes = next;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Temp file next to the target, synced, then renamed over it; the old file stays if anything fails. */
|
||||||
|
private void write(Map<String, String> accounts) {
|
||||||
|
Path directory = file.toAbsolutePath().getParent();
|
||||||
|
Path temp = null;
|
||||||
|
try {
|
||||||
|
temp = Files.createTempFile(directory, "users", ".tmp",
|
||||||
|
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
||||||
|
Files.write(temp, JSON.writerWithDefaultPrettyPrinter().writeValueAsBytes(accounts.entrySet().stream()
|
||||||
|
.map(e -> new Entry(e.getKey(), e.getValue())).toList()));
|
||||||
|
try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
|
||||||
|
channel.force(true);
|
||||||
|
}
|
||||||
|
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
} catch (IOException e) {
|
||||||
|
if (temp != null) {
|
||||||
|
temp.toFile().delete();
|
||||||
|
}
|
||||||
|
throw new UncheckedIOException("cannot write USERS_FILE " + file, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static char[] randomPassword() {
|
||||||
|
byte[] bytes = new byte[24];
|
||||||
|
new SecureRandom().nextBytes(bytes);
|
||||||
|
return Base64.getEncoder().encodeToString(bytes).toCharArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ import java.util.stream.Collectors;
|
|||||||
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
|
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
|
||||||
public class GatewayHttpClient implements GatewayClient {
|
public class GatewayHttpClient implements GatewayClient {
|
||||||
|
|
||||||
private record Health(List<DeviceHealth> devices) {
|
private record Health(String version, List<DeviceHealth> devices) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
|
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
|
||||||
@@ -61,7 +61,7 @@ public class GatewayHttpClient implements GatewayClient {
|
|||||||
device.lastSuccess(), device.lastError(),
|
device.lastSuccess(), device.lastError(),
|
||||||
measurements.getOrDefault(device.name(), List.of())))
|
measurements.getOrDefault(device.name(), List.of())))
|
||||||
.toList();
|
.toList();
|
||||||
return new GatewaySnapshot(devices);
|
return new GatewaySnapshot(health.version(), devices);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Measurement> measurements(DeviceReadings device) {
|
private static List<Measurement> measurements(DeviceReadings device) {
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ package home.backend.adapters.gateway;
|
|||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import home.backend.application.GatewayBattery;
|
||||||
import home.backend.application.GatewayCommands;
|
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.Command;
|
||||||
import home.backend.domain.CommandResult;
|
import home.backend.domain.CommandResult;
|
||||||
|
import home.backend.domain.RefreshResult;
|
||||||
|
import home.backend.domain.RestoreResult;
|
||||||
import home.backend.domain.SetpointState;
|
import home.backend.domain.SetpointState;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -15,23 +23,54 @@ import java.net.http.HttpClient;
|
|||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */
|
/** Calls the gateway's {@code /commands}, {@code /battery} and {@code /refresh} with the shared token (S6). */
|
||||||
public class GatewayHttpCommands implements GatewayCommands {
|
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery, GatewayRefresh {
|
||||||
|
|
||||||
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
|
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
|
||||||
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
|
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
|
||||||
|
|
||||||
|
/** Restore now: 200 restored, 404 nothing pending, 409 changed meanwhile, 429 too soon, 502 failed. */
|
||||||
|
private static final Set<Integer> RESTORE_VERDICTS = Set.of(200, 404, 409, 429, 502);
|
||||||
|
|
||||||
|
/** Battery change: 200 applied, 422 rejected, 409 stale or changed meanwhile, 429 too soon, 502 failed. */
|
||||||
|
private static final Set<Integer> BATTERY_VERDICTS = Set.of(200, 422, 409, 429, 502);
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
private record Request(String setpoint, int value, Long minutes) {
|
private record ScheduleRequest(Boolean enabled, List<SlotRequest> slots) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
private record SlotRequest(int slot, Integer start, Integer soc, Boolean gridCharge) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ReserveRequest(int pct) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
private record Request(String setpoint, int value, Long minutes, Instant until) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RestoreRequest(String setpoint) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Renewal(int renewed) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final ObjectMapper JSON = new ObjectMapper()
|
private static final ObjectMapper JSON = new ObjectMapper()
|
||||||
.registerModule(new JavaTimeModule())
|
.registerModule(new JavaTimeModule())
|
||||||
|
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
|
||||||
private final URI endpoint;
|
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;
|
private final String token;
|
||||||
private final Duration timeout;
|
private final Duration timeout;
|
||||||
private final HttpClient http;
|
private final HttpClient http;
|
||||||
@@ -39,6 +78,11 @@ public class GatewayHttpCommands implements GatewayCommands {
|
|||||||
/** @param token null when not configured: every call then fails without touching the network */
|
/** @param token null when not configured: every call then fails without touching the network */
|
||||||
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
|
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
|
||||||
this.endpoint = baseUri.resolve("/commands");
|
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;
|
this.token = token;
|
||||||
this.timeout = timeout;
|
this.timeout = timeout;
|
||||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||||
@@ -47,35 +91,84 @@ public class GatewayHttpCommands implements GatewayCommands {
|
|||||||
@Override
|
@Override
|
||||||
public CommandResult apply(Command command) throws IOException {
|
public CommandResult apply(Command command) throws IOException {
|
||||||
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
|
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
|
||||||
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes));
|
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes, command.heldUntil()));
|
||||||
HttpRequest.Builder request = request().header("Content-Type", "application/json")
|
HttpRequest.Builder request = request().header("Content-Type", "application/json")
|
||||||
.header("X-Requested-By", command.requestedBy())
|
.header("X-Requested-By", command.requestedBy())
|
||||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||||
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
|
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||||
|
byte[] body = JSON.writeValueAsBytes(new RestoreRequest(setpoint));
|
||||||
|
var request = request(restoreEndpoint).header("Content-Type", "application/json")
|
||||||
|
.header("X-Requested-By", requestedBy).POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||||
|
return JSON.readValue(send(request, RESTORE_VERDICTS), RestoreResult.class);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SetpointState setpoints() throws IOException {
|
public SetpointState setpoints() throws IOException {
|
||||||
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
|
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int renewLeases() throws IOException {
|
||||||
|
var request = request(renewEndpoint).POST(HttpRequest.BodyPublishers.noBody());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
|
||||||
|
var slots = change.slots().stream().map(slot -> new SlotRequest(slot.slot(),
|
||||||
|
slot.from() == null ? null : slot.from().getHour() * 100 + slot.from().getMinute(),
|
||||||
|
slot.socPct(), slot.gridCharge())).toList();
|
||||||
|
return battery(scheduleEndpoint, new ScheduleRequest(change.enabled(), slots), requestedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
|
||||||
|
return battery(reserveEndpoint, new ReserveRequest(pct), requestedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BatteryResult battery(URI uri, Object body, String requestedBy) throws IOException {
|
||||||
|
var request = request(uri).header("Content-Type", "application/json").header("X-Requested-By", requestedBy)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofByteArray(JSON.writeValueAsBytes(body)));
|
||||||
|
return JSON.readValue(send(request, BATTERY_VERDICTS), BatteryResult.class);
|
||||||
|
}
|
||||||
|
|
||||||
private HttpRequest.Builder request() throws IOException {
|
private HttpRequest.Builder request() throws IOException {
|
||||||
|
return request(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequest.Builder request(URI uri) throws IOException {
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
|
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
|
||||||
}
|
}
|
||||||
return HttpRequest.newBuilder(endpoint).timeout(timeout).header("Authorization", "Bearer " + token);
|
return HttpRequest.newBuilder(uri).timeout(timeout).header("Authorization", "Bearer " + token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
|
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
|
||||||
try {
|
try {
|
||||||
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
|
var built = request.build();
|
||||||
|
var response = http.send(built, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
if (!accepted.contains(response.statusCode())) {
|
if (!accepted.contains(response.statusCode())) {
|
||||||
throw new IOException("gateway /commands answered " + response.statusCode());
|
throw new IOException("gateway " + built.uri().getPath() + " answered " + response.statusCode());
|
||||||
}
|
}
|
||||||
return response.body();
|
return response.body();
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
throw new IOException("interrupted while calling gateway /commands", e);
|
throw new IOException("interrupted while calling the gateway", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,15 +9,28 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
|||||||
import com.sun.net.httpserver.HttpExchange;
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
import com.sun.net.httpserver.HttpHandler;
|
import com.sun.net.httpserver.HttpHandler;
|
||||||
import com.sun.net.httpserver.HttpServer;
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import home.backend.adapters.auth.Authentication;
|
||||||
|
import home.backend.application.AuditService;
|
||||||
|
import home.backend.application.BatteryService;
|
||||||
|
import home.backend.application.EnergyDetailsService;
|
||||||
import home.backend.application.EnergyService;
|
import home.backend.application.EnergyService;
|
||||||
|
import home.backend.application.GatewayRefresh;
|
||||||
import home.backend.application.HealthService;
|
import home.backend.application.HealthService;
|
||||||
import home.backend.application.HeatPumpService;
|
import home.backend.application.HeatPumpService;
|
||||||
import home.backend.application.HistoryService;
|
import home.backend.application.HistoryService;
|
||||||
|
import home.backend.application.StatsService;
|
||||||
|
import home.backend.application.PiSensorService;
|
||||||
import home.backend.application.SetpointService;
|
import home.backend.application.SetpointService;
|
||||||
|
import home.backend.application.WeatherService;
|
||||||
|
import home.backend.domain.BatteryChange;
|
||||||
|
import home.backend.domain.BatteryResult;
|
||||||
import home.backend.domain.CommandResult.Status;
|
import home.backend.domain.CommandResult.Status;
|
||||||
import home.backend.domain.PresetResult;
|
import home.backend.domain.PresetResult;
|
||||||
|
import home.backend.domain.RestoreResult;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.net.URLDecoder;
|
import java.net.URLDecoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -37,17 +50,30 @@ public class ApiServer {
|
|||||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
|
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
|
||||||
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
|
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
|
||||||
|
|
||||||
|
private record ReserveRequest(Integer pct) {
|
||||||
|
}
|
||||||
|
|
||||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
|
||||||
|
private record PresetRequest(String until, Map<String, Integer> values) {
|
||||||
|
|
||||||
|
PresetRequest {
|
||||||
|
if (values == null) {
|
||||||
|
values = Map.of();
|
||||||
|
} else if (values.containsValue(null)) {
|
||||||
|
throw new IllegalArgumentException("a value must be a number");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static final String UNREACHABLE = "gateway unreachable";
|
private static final String UNREACHABLE = "gateway unreachable";
|
||||||
|
private static final String UNAVAILABLE = "battery changes are not available: gateway unreachable or not enabled";
|
||||||
private static final String WRITE_MARKER = "home-pump";
|
private static final String WRITE_MARKER = "home-pump";
|
||||||
private static final String PRESETS_PATH = "/api/presets/";
|
private static final String PRESETS_PATH = "/api/presets/";
|
||||||
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
|
private static final String SETPOINTS_PATH = "/api/setpoints/";
|
||||||
private static final String USER_HEADER = "Remote-User";
|
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
|
||||||
private static final String DEFAULT_USER = "phone";
|
|
||||||
private static final Pattern USER_NAME = Pattern.compile("[\\w.@+-]{1,64}");
|
|
||||||
|
|
||||||
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
|
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
|
||||||
|
|
||||||
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
|
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
|
||||||
@@ -55,33 +81,85 @@ public class ApiServer {
|
|||||||
private static final int STOP_GRACE_SECONDS = 5;
|
private static final int STOP_GRACE_SECONDS = 5;
|
||||||
|
|
||||||
private final HttpServer server;
|
private final HttpServer server;
|
||||||
|
private final SessionFilter sessionFilter;
|
||||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
|
|
||||||
/** Binds the port now; 0 picks a free one. */
|
/** Binds the port now; 0 picks a free one. */
|
||||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, HealthService health,
|
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||||
HistoryService history, SetpointService setpoints) throws IOException {
|
EnergyDetailsService energyDetails, HealthService health,
|
||||||
|
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
|
||||||
|
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather,
|
||||||
|
GatewayRefresh refresh, Authentication auth) throws IOException {
|
||||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||||
server.setExecutor(executor);
|
server.setExecutor(executor);
|
||||||
|
sessionFilter = new SessionFilter(auth);
|
||||||
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
|
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
|
||||||
context("/", exchange -> {
|
open("/", exchange -> {
|
||||||
try (exchange) {
|
try (exchange) {
|
||||||
exchange.sendResponseHeaders(404, -1);
|
exchange.sendResponseHeaders(404, -1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
var signIn = new AuthEndpoints(auth);
|
||||||
|
openRoute("/api/auth/config", signIn::config);
|
||||||
|
openRoute("/api/auth/login", signIn::login);
|
||||||
|
openRoute("/api/auth/logout", signIn::logout);
|
||||||
|
openRoute("/api/auth/me", signIn::me);
|
||||||
|
openRoute("/api/auth/setup", signIn::setup);
|
||||||
|
openRoute("/api/auth/users", signIn::users);
|
||||||
|
openRoute("/api/auth/password", signIn::password);
|
||||||
|
open("/api/auth/users/", signIn::deleteUser);
|
||||||
|
openRoute("/api/auth/oidc/start", signIn::oidcStart);
|
||||||
|
openRoute("/api/auth/oidc/callback", signIn::oidcCallback);
|
||||||
|
// The container healthcheck: no session, nothing in the answer.
|
||||||
|
openRoute("/api/live", exchange -> handle(exchange, Map.of("GET", () -> {
|
||||||
|
exchange.sendResponseHeaders(200, 2);
|
||||||
|
exchange.getResponseBody().write("ok".getBytes(StandardCharsets.US_ASCII));
|
||||||
|
})));
|
||||||
api("/api/heat-pump", query -> heatPump.current());
|
api("/api/heat-pump", query -> heatPump.current());
|
||||||
api("/api/energy", query -> energy.current());
|
api("/api/energy", query -> energy.current());
|
||||||
|
api("/api/energy/details", query -> energyDetails.current());
|
||||||
|
api("/api/battery", query -> battery.current());
|
||||||
|
route("/api/battery/schedule", exchange -> handle(exchange, Map.of(
|
||||||
|
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
|
||||||
|
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
|
||||||
|
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
|
||||||
|
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());
|
api("/api/health", query -> health.current());
|
||||||
route("/api/history", exchange -> handleHistory(exchange, history));
|
route("/api/history", exchange -> handleHistory(exchange, history));
|
||||||
|
route("/api/audit", exchange -> handleAudit(exchange, audit));
|
||||||
|
route("/api/stats", exchange -> handleStats(exchange, stats));
|
||||||
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
||||||
"GET", () -> getSetpoints(exchange, setpoints),
|
"GET", () -> getSetpoints(exchange, setpoints),
|
||||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||||
|
context(SETPOINTS_PATH, exchange -> {
|
||||||
|
var restore = RESTORE_PATH.matcher(exchange.getRequestURI().getPath());
|
||||||
|
if (restore.matches()) {
|
||||||
|
handle(exchange, Map.of("POST", () -> guarded(exchange,
|
||||||
|
body -> postRestore(exchange, restore.group(1), setpoints))));
|
||||||
|
} else {
|
||||||
|
try (exchange) {
|
||||||
|
exchange.sendResponseHeaders(404, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
|
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
|
||||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, setpoints)))));
|
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void route(String path, HttpHandler handler) {
|
||||||
|
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. */
|
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||||
private void route(String path, HttpHandler handler) {
|
private static HttpHandler exactly(String path, HttpHandler handler) {
|
||||||
context(path, exchange -> {
|
return exchange -> {
|
||||||
if (path.equals(exchange.getRequestURI().getPath())) {
|
if (path.equals(exchange.getRequestURI().getPath())) {
|
||||||
handler.handle(exchange);
|
handler.handle(exchange);
|
||||||
} else {
|
} else {
|
||||||
@@ -89,10 +167,18 @@ public class ApiServer {
|
|||||||
exchange.sendResponseHeaders(404, -1);
|
exchange.sendResponseHeaders(404, -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Needs a session. */
|
||||||
private void context(String path, HttpHandler handler) {
|
private void context(String path, HttpHandler handler) {
|
||||||
|
var context = server.createContext(path, handler);
|
||||||
|
context.getFilters().add(SECURITY_HEADERS);
|
||||||
|
context.getFilters().add(sessionFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answers without a session. */
|
||||||
|
private void open(String path, HttpHandler handler) {
|
||||||
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
|
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +214,7 @@ public class ApiServer {
|
|||||||
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
|
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
|
||||||
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
|
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
|
||||||
*/
|
*/
|
||||||
private static void guarded(HttpExchange exchange, Write write) throws IOException {
|
static void guarded(HttpExchange exchange, Write write) throws IOException {
|
||||||
var headers = exchange.getRequestHeaders();
|
var headers = exchange.getRequestHeaders();
|
||||||
String origin = headers.getFirst("Origin");
|
String origin = headers.getFirst("Origin");
|
||||||
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
|
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
|
||||||
@@ -175,15 +261,71 @@ public class ApiServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void postPreset(HttpExchange exchange, SetpointService setpoints) throws IOException {
|
private static void postSchedule(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
|
||||||
|
try {
|
||||||
|
var change = JSON.readValue(body, BatteryChange.class);
|
||||||
|
if (change == null) {
|
||||||
|
throw new IllegalArgumentException("a change is required");
|
||||||
|
}
|
||||||
|
var result = battery.changeSchedule(change, user(exchange));
|
||||||
|
send(exchange, batteryHttpStatus(result.status()), result);
|
||||||
|
} catch (JsonProcessingException | IllegalArgumentException e) {
|
||||||
|
send(exchange, 400, error("expected {\"enabled\"?, \"slots\": [{\"slot\", \"from\"?: \"HH:MM\", "
|
||||||
|
+ "\"socPct\"?, \"gridCharge\"?}]}"));
|
||||||
|
} catch (IOException e) {
|
||||||
|
send(exchange, 503, error(UNAVAILABLE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void postReserve(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
|
||||||
|
try {
|
||||||
|
var request = JSON.readValue(body, ReserveRequest.class);
|
||||||
|
if (request == null || request.pct() == null) {
|
||||||
|
throw new IllegalArgumentException("pct is required");
|
||||||
|
}
|
||||||
|
var result = battery.changeReserve(request.pct(), user(exchange));
|
||||||
|
send(exchange, batteryHttpStatus(result.status()), result);
|
||||||
|
} catch (JsonProcessingException | IllegalArgumentException e) {
|
||||||
|
send(exchange, 400, error("expected {\"pct\"}"));
|
||||||
|
} catch (IOException e) {
|
||||||
|
send(exchange, 503, error(UNAVAILABLE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void 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 {
|
||||||
|
var result = setpoints.restore(setpoint, user(exchange));
|
||||||
|
send(exchange, restoreHttpStatus(result.outcome()), result);
|
||||||
|
} catch (IOException e) {
|
||||||
|
send(exchange, 503, error(UNREACHABLE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void postPreset(HttpExchange exchange, byte[] body, SetpointService setpoints)
|
||||||
|
throws IOException {
|
||||||
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
||||||
try {
|
try {
|
||||||
var result = setpoints.applyPreset(id, user(exchange));
|
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
|
||||||
|
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
|
||||||
|
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
|
||||||
if (result.isPresent()) {
|
if (result.isPresent()) {
|
||||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||||
} else {
|
} else {
|
||||||
send(exchange, 404, error("no such preset"));
|
send(exchange, 404, error("no such preset"));
|
||||||
}
|
}
|
||||||
|
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
|
||||||
|
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
|
||||||
|
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
|
||||||
|
+ "for setpoints of the preset"));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
send(exchange, 503, error(UNREACHABLE));
|
send(exchange, 503, error(UNREACHABLE));
|
||||||
}
|
}
|
||||||
@@ -197,6 +339,26 @@ public class ApiServer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int batteryHttpStatus(BatteryResult.Status status) {
|
||||||
|
return switch (status) {
|
||||||
|
case APPLIED -> 200;
|
||||||
|
case REJECTED -> 422;
|
||||||
|
case STALE -> 409;
|
||||||
|
case TOO_SOON -> 429;
|
||||||
|
case FAILED -> 502;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
|
||||||
|
return switch (outcome) {
|
||||||
|
case RESTORED -> 200;
|
||||||
|
case NOT_PENDING -> 404;
|
||||||
|
case CONFLICT -> 409;
|
||||||
|
case TOO_SOON -> 429;
|
||||||
|
case FAILED -> 502;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static int presetHttpStatus(PresetResult.Status status) {
|
private static int presetHttpStatus(PresetResult.Status status) {
|
||||||
return switch (status) {
|
return switch (status) {
|
||||||
case APPLIED -> 200;
|
case APPLIED -> 200;
|
||||||
@@ -205,9 +367,9 @@ public class ApiServer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The signed-in user, set by the session filter. */
|
||||||
private static String user(HttpExchange exchange) {
|
private static String user(HttpExchange exchange) {
|
||||||
String name = exchange.getRequestHeaders().getFirst(USER_HEADER);
|
return (String) exchange.getAttribute(SessionFilter.USER_ATTRIBUTE);
|
||||||
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
||||||
@@ -223,15 +385,40 @@ 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"));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Action {
|
||||||
void run() throws IOException;
|
void run() throws IOException;
|
||||||
}
|
}
|
||||||
|
|
||||||
private interface Write {
|
interface Write {
|
||||||
void run(byte[] body) throws IOException;
|
void run(byte[] body) throws IOException;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
|
static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
|
||||||
try (exchange) {
|
try (exchange) {
|
||||||
Action action = byMethod.get(exchange.getRequestMethod());
|
Action action = byMethod.get(exchange.getRequestMethod());
|
||||||
if (action == null) {
|
if (action == null) {
|
||||||
@@ -250,7 +437,7 @@ public class ApiServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void send(HttpExchange exchange, int status, Object body) throws IOException {
|
static void send(HttpExchange exchange, int status, Object body) throws IOException {
|
||||||
byte[] bytes = JSON.writeValueAsBytes(body);
|
byte[] bytes = JSON.writeValueAsBytes(body);
|
||||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||||
@@ -262,7 +449,7 @@ public class ApiServer {
|
|||||||
return Map.of("error", message);
|
return Map.of("error", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Map<String, String> parseQuery(String raw) {
|
static Map<String, String> parseQuery(String raw) {
|
||||||
Map<String, String> query = new HashMap<>();
|
Map<String, String> query = new HashMap<>();
|
||||||
if (raw == null) {
|
if (raw == null) {
|
||||||
return query;
|
return query;
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package home.backend.adapters.http;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import home.backend.adapters.auth.Authentication;
|
||||||
|
import home.backend.adapters.auth.OidcException;
|
||||||
|
import home.backend.adapters.auth.Session;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** The endpoints under {@code /api/auth/}: sign-in needs no session, managing accounts does. */
|
||||||
|
final class AuthEndpoints {
|
||||||
|
|
||||||
|
private record LoginRequest(String username, String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record PasswordRequest(String current, @JsonProperty("new") String next) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Pattern DELETE_PATH = Pattern.compile("/api/auth/users/([^/]{1,64})/delete");
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
private static final int MAX_CREDENTIAL_LENGTH = 256;
|
||||||
|
|
||||||
|
private final Authentication auth;
|
||||||
|
|
||||||
|
AuthEndpoints(Authentication auth) {
|
||||||
|
this.auth = auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
void config(HttpExchange exchange) throws IOException {
|
||||||
|
ApiServer.handle(exchange, Map.of("GET", () -> ApiServer.send(exchange, 200,
|
||||||
|
Map.of("local", auth.localEnabled(), "oidc", auth.oidcEnabled(),
|
||||||
|
"setup", auth.setupNeeded()))));
|
||||||
|
}
|
||||||
|
|
||||||
|
void login(HttpExchange exchange) throws IOException {
|
||||||
|
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> postLogin(exchange, body))));
|
||||||
|
}
|
||||||
|
|
||||||
|
void logout(HttpExchange exchange) throws IOException {
|
||||||
|
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
|
||||||
|
String cookie = auth.clearedSessionCookie();
|
||||||
|
if (!cookie.isEmpty()) {
|
||||||
|
exchange.getResponseHeaders().add("Set-Cookie", cookie);
|
||||||
|
}
|
||||||
|
exchange.sendResponseHeaders(204, -1);
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
void me(HttpExchange exchange) throws IOException {
|
||||||
|
ApiServer.handle(exchange, Map.of("GET", () -> {
|
||||||
|
var session = auth.session(SessionFilter.cookies(exchange));
|
||||||
|
if (session.isPresent()) {
|
||||||
|
ApiServer.send(exchange, 200, user(session.get()));
|
||||||
|
} else {
|
||||||
|
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void oidcStart(HttpExchange exchange) throws IOException {
|
||||||
|
ApiServer.handle(exchange, Map.of("GET", () -> {
|
||||||
|
if (!auth.oidcEnabled()) {
|
||||||
|
exchange.sendResponseHeaders(404, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var start = auth.oidc().start();
|
||||||
|
exchange.getResponseHeaders().add("Set-Cookie", start.setCookie());
|
||||||
|
redirect(exchange, start.location().toString());
|
||||||
|
} catch (OidcException e) {
|
||||||
|
redirectWithError(exchange, e);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void oidcCallback(HttpExchange exchange) throws IOException {
|
||||||
|
ApiServer.handle(exchange, Map.of("GET", () -> {
|
||||||
|
if (!auth.oidcEnabled()) {
|
||||||
|
exchange.sendResponseHeaders(404, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exchange.getResponseHeaders().add("Set-Cookie", auth.oidc().clearCookie());
|
||||||
|
try {
|
||||||
|
String name = auth.oidc().callback(ApiServer.parseQuery(exchange.getRequestURI().getRawQuery()),
|
||||||
|
SessionFilter.cookies(exchange));
|
||||||
|
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(name, "oidc"));
|
||||||
|
redirect(exchange, "/");
|
||||||
|
} catch (OidcException e) {
|
||||||
|
redirectWithError(exchange, e);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postLogin(HttpExchange exchange, byte[] body) throws IOException {
|
||||||
|
if (!auth.localEnabled()) {
|
||||||
|
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LoginRequest request = credentials(exchange, body);
|
||||||
|
if (request == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String ip = ClientIp.of(exchange, auth.proxyHops());
|
||||||
|
var login = auth.login(request.username(), request.password().toCharArray(), ip);
|
||||||
|
switch (login.status()) {
|
||||||
|
case OK -> {
|
||||||
|
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
|
||||||
|
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
|
||||||
|
}
|
||||||
|
case INVALID -> {
|
||||||
|
System.err.println("home-pump-backend: failed login from " + ip);
|
||||||
|
ApiServer.send(exchange, 401, Map.of("error", "invalid"));
|
||||||
|
}
|
||||||
|
case THROTTLED -> throttled(exchange, login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The username and password of the body, or null after answering 400. */
|
||||||
|
private static LoginRequest credentials(HttpExchange exchange, byte[] body) throws IOException {
|
||||||
|
LoginRequest request;
|
||||||
|
try {
|
||||||
|
request = JSON.readValue(body, LoginRequest.class);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
request = null;
|
||||||
|
}
|
||||||
|
if (request == null || request.username() == null || request.password() == null
|
||||||
|
|| request.username().length() > MAX_CREDENTIAL_LENGTH
|
||||||
|
|| request.password().length() > MAX_CREDENTIAL_LENGTH) {
|
||||||
|
ApiServer.send(exchange, 400, Map.of("error", "expected {\"username\", \"password\"}"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void throttled(HttpExchange exchange, Authentication.Login login) throws IOException {
|
||||||
|
exchange.getResponseHeaders().set("Retry-After", String.valueOf(login.retryAfterSeconds()));
|
||||||
|
ApiServer.send(exchange, 429, Map.of("error", "throttled", "retryAfterSeconds", login.retryAfterSeconds()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the name and password may become an account; else answers 400. */
|
||||||
|
private static boolean acceptable(HttpExchange exchange, String username, String password) throws IOException {
|
||||||
|
if (!Session.isValidUser(username)) {
|
||||||
|
ApiServer.send(exchange, 400, Map.of("error", "invalid_username"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Authentication.strongEnough(password)) {
|
||||||
|
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup(HttpExchange exchange) throws IOException {
|
||||||
|
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
|
||||||
|
if (!auth.localEnabled()) {
|
||||||
|
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LoginRequest request = credentials(exchange, body);
|
||||||
|
if (request == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!auth.setupNeeded()) {
|
||||||
|
ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!acceptable(exchange, request.username(), request.password())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var result = auth.setup(request.username(), request.password().toCharArray(),
|
||||||
|
ClientIp.of(exchange, auth.proxyHops()));
|
||||||
|
switch (result.status()) {
|
||||||
|
case OK -> {
|
||||||
|
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
|
||||||
|
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
|
||||||
|
}
|
||||||
|
case INVALID -> ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
|
||||||
|
case THROTTLED -> throttled(exchange, result);
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the action for a signed-in user of a configured local store; else answers 401 or 404. */
|
||||||
|
private void asUser(HttpExchange exchange, SignedIn action) throws IOException {
|
||||||
|
var session = auth.session(SessionFilter.cookies(exchange));
|
||||||
|
if (session.isEmpty()) {
|
||||||
|
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||||
|
} else if (!auth.localEnabled()) {
|
||||||
|
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||||
|
} else {
|
||||||
|
action.run(session.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface SignedIn {
|
||||||
|
void run(Session session) throws IOException;
|
||||||
|
}
|
||||||
|
|
||||||
|
void users(HttpExchange exchange) throws IOException {
|
||||||
|
ApiServer.handle(exchange, Map.of(
|
||||||
|
"GET", () -> asUser(exchange, session -> ApiServer.send(exchange, 200,
|
||||||
|
auth.usernames().stream().map(name -> Map.of("username", name)).toList())),
|
||||||
|
"POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||||
|
LoginRequest request = credentials(exchange, body);
|
||||||
|
if (request == null || !acceptable(exchange, request.username(), request.password())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (auth.addUser(session.user(), request.username(), request.password().toCharArray())) {
|
||||||
|
ApiServer.send(exchange, 200, Map.of("username", request.username()));
|
||||||
|
} else {
|
||||||
|
ApiServer.send(exchange, 409, Map.of("error", "exists"));
|
||||||
|
}
|
||||||
|
}))));
|
||||||
|
}
|
||||||
|
|
||||||
|
void password(HttpExchange exchange) throws IOException {
|
||||||
|
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||||
|
PasswordRequest request;
|
||||||
|
try {
|
||||||
|
request = JSON.readValue(body, PasswordRequest.class);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
request = null;
|
||||||
|
}
|
||||||
|
if (request == null || request.current() == null || request.next() == null
|
||||||
|
|| request.current().length() > MAX_CREDENTIAL_LENGTH
|
||||||
|
|| request.next().length() > MAX_CREDENTIAL_LENGTH) {
|
||||||
|
ApiServer.send(exchange, 400, Map.of("error", "expected {\"current\", \"new\"}"));
|
||||||
|
} else if (!Authentication.LOCAL.equals(session.method())) {
|
||||||
|
ApiServer.send(exchange, 403, Map.of("error", "local_only"));
|
||||||
|
} else if (!Authentication.strongEnough(request.next())) {
|
||||||
|
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
|
||||||
|
} else {
|
||||||
|
var result = auth.changePassword(session.user(), request.current().toCharArray(),
|
||||||
|
request.next().toCharArray(), ClientIp.of(exchange, auth.proxyHops()));
|
||||||
|
switch (result.status()) {
|
||||||
|
case OK -> exchange.sendResponseHeaders(204, -1);
|
||||||
|
case INVALID -> ApiServer.send(exchange, 401, Map.of("error", "invalid"));
|
||||||
|
case THROTTLED -> throttled(exchange, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))));
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteUser(HttpExchange exchange) throws IOException {
|
||||||
|
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||||
|
var path = DELETE_PATH.matcher(exchange.getRequestURI().getRawPath());
|
||||||
|
if (!path.matches()) {
|
||||||
|
exchange.sendResponseHeaders(404, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String name = path.group(1);
|
||||||
|
switch (auth.removeUser(session.user(), name)) {
|
||||||
|
case UNKNOWN -> ApiServer.send(exchange, 404, Map.of("error", "unknown_user"));
|
||||||
|
case LAST -> ApiServer.send(exchange, 409, Map.of("error", "last_user"));
|
||||||
|
case REMOVED -> {
|
||||||
|
if (name.equals(session.user())) {
|
||||||
|
exchange.getResponseHeaders().add("Set-Cookie", auth.clearedSessionCookie());
|
||||||
|
}
|
||||||
|
exchange.sendResponseHeaders(204, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> user(Session session) {
|
||||||
|
return Map.of("user", session.user(), "method", session.method());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void redirectWithError(HttpExchange exchange, OidcException e) throws IOException {
|
||||||
|
System.err.println("home-pump-backend: OIDC sign-in failed: " + e.getMessage());
|
||||||
|
redirect(exchange, "/#login?error=" + e.reason().code());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void redirect(HttpExchange exchange, String location) throws IOException {
|
||||||
|
exchange.getResponseHeaders().set("Location", location);
|
||||||
|
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||||
|
exchange.sendResponseHeaders(302, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package home.backend.adapters.http;
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client's address for the login throttle. Each trusted proxy appends the address it saw to
|
||||||
|
* X-Forwarded-For, so the entry {@code hops} from the right is the first one no trusted proxy vouches for
|
||||||
|
* beyond that; anything the client put in front of it is ignored. Without enough entries the request did not
|
||||||
|
* come through the proxies, and the socket's address is used.
|
||||||
|
*/
|
||||||
|
final class ClientIp {
|
||||||
|
|
||||||
|
private ClientIp() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String of(HttpExchange exchange, int hops) {
|
||||||
|
List<String> values = exchange.getRequestHeaders().get("X-Forwarded-For");
|
||||||
|
if (values != null) {
|
||||||
|
List<String> chain = Arrays.stream(String.join(",", values).split(",")).map(String::trim).toList();
|
||||||
|
if (chain.size() >= hops && !chain.get(chain.size() - hops).isEmpty()) {
|
||||||
|
return chain.get(chain.size() - hops);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exchange.getRemoteAddress().getAddress().getHostAddress();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package home.backend.adapters.http;
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.Filter;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import home.backend.adapters.auth.Authentication;
|
||||||
|
import home.backend.adapters.auth.Session;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/** Lets a request through only with a valid session, and hands the user's name on to the handlers. */
|
||||||
|
final class SessionFilter extends Filter {
|
||||||
|
|
||||||
|
static final String USER_ATTRIBUTE = "user";
|
||||||
|
|
||||||
|
private final Authentication auth;
|
||||||
|
|
||||||
|
SessionFilter(Authentication auth) {
|
||||||
|
this.auth = auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
|
||||||
|
Optional<Session> session = auth.session(cookies(exchange));
|
||||||
|
if (session.isPresent()) {
|
||||||
|
exchange.setAttribute(USER_ATTRIBUTE, session.get().user());
|
||||||
|
chain.doFilter(exchange);
|
||||||
|
} else {
|
||||||
|
try (exchange) {
|
||||||
|
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String cookies(HttpExchange exchange) {
|
||||||
|
var values = exchange.getRequestHeaders().get("Cookie");
|
||||||
|
return values == null ? null : String.join("; ", values);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "session check";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
package home.backend.adapters.influx;
|
||||||
|
|
||||||
|
import home.backend.application.HeatPumpSamples;
|
||||||
import home.backend.application.HistoryRepository;
|
import home.backend.application.HistoryRepository;
|
||||||
|
import home.backend.domain.HeatPumpSample;
|
||||||
import home.backend.domain.HistoryView.Point;
|
import home.backend.domain.HistoryView.Point;
|
||||||
import home.backend.domain.Series;
|
import home.backend.domain.Series;
|
||||||
|
|
||||||
import java.io.IOException;
|
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.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -20,14 +16,12 @@ import java.util.concurrent.ExecutionException;
|
|||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
|
/** 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 InfluxClient influx;
|
||||||
private final HttpClient http;
|
|
||||||
|
|
||||||
public InfluxHistoryRepository(InfluxSettings settings) {
|
public InfluxHistoryRepository(InfluxSettings settings) {
|
||||||
this.settings = settings;
|
this.influx = new InfluxClient(settings);
|
||||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -57,39 +51,19 @@ public final class InfluxHistoryRepository implements HistoryRepository {
|
|||||||
.toList();
|
.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 {
|
private Map<Long, Double> part(Source source, Instant from, Instant to, long stepMinutes) throws IOException {
|
||||||
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
|
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) {
|
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
|
||||||
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
|
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
|
||||||
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
|
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String fetch(String database, String query) throws IOException {
|
|
||||||
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
|
|
||||||
.timeout(settings.requestTimeout())
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
try {
|
|
||||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
if (response.statusCode() != 200) {
|
|
||||||
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
|
|
||||||
}
|
|
||||||
return response.body();
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new IOException("InfluxDB request interrupted", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private URI queryUri(String database, String query) {
|
|
||||||
String base = settings.baseUri().toString().replaceAll("/+$", "");
|
|
||||||
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String encode(String value) {
|
|
||||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package home.backend.adapters.influx;
|
||||||
|
|
||||||
|
import home.backend.application.PvEnergyRepository;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
/** Daily PV energy: the Deye's and the Fronius's own day counters, each read as the day's maximum. */
|
||||||
|
public final class InfluxPvEnergy implements PvEnergyRepository {
|
||||||
|
|
||||||
|
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||||
|
private static final String DATABASE = "electricity";
|
||||||
|
|
||||||
|
private final InfluxClient influx;
|
||||||
|
|
||||||
|
public InfluxPvEnergy(InfluxSettings settings) {
|
||||||
|
this.influx = new InfluxClient(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException {
|
||||||
|
Map<LocalDate, Double> deye = dailyMax("deye", "pv_today_kwh", from, to);
|
||||||
|
Map<LocalDate, Double> fronius = dailyMax("fronius", "energy_day_wh", from, to);
|
||||||
|
Map<LocalDate, Double> total = new TreeMap<>();
|
||||||
|
deye.forEach((day, kwh) -> {
|
||||||
|
Double wh = fronius.get(day);
|
||||||
|
if (wh != null) {
|
||||||
|
total.put(day, kwh + wh / 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<LocalDate, Double> dailyMax(String measurement, String field, LocalDate from, LocalDate to)
|
||||||
|
throws IOException {
|
||||||
|
String query = "SELECT max(\"" + field + "\") FROM \"" + measurement + "\" WHERE time >= "
|
||||||
|
+ millis(from) + "ms AND time < " + millis(to) + "ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')";
|
||||||
|
Map<LocalDate, Double> days = new TreeMap<>();
|
||||||
|
InfluxResponse.parse(influx.query(DATABASE, query)).forEach((start, max) ->
|
||||||
|
days.put(Instant.ofEpochMilli(start).atZone(ZONE).toLocalDate(), max));
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long millis(LocalDate day) {
|
||||||
|
return day.atStartOfDay(ZONE).toInstant().toEpochMilli();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package home.backend.adapters.influx;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import home.backend.domain.HeatPumpSample;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** The raw {@code heat_pump} rows of an InfluxDB 1.x query. */
|
||||||
|
final class InfluxSamples {
|
||||||
|
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
|
||||||
|
private InfluxSamples() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String query(long fromMs, long toMs) {
|
||||||
|
return "SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
|
||||||
|
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rows of {@link #query}, as returned (ascending); empty when there is no data. */
|
||||||
|
static List<HeatPumpSample> parse(String body) throws IOException {
|
||||||
|
JsonNode root;
|
||||||
|
try {
|
||||||
|
root = JSON.readTree(body);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||||
|
}
|
||||||
|
failOnError(root);
|
||||||
|
List<HeatPumpSample> samples = new ArrayList<>();
|
||||||
|
for (JsonNode result : root.path("results")) {
|
||||||
|
failOnError(result);
|
||||||
|
for (JsonNode series : result.path("series")) {
|
||||||
|
for (JsonNode row : series.path("values")) {
|
||||||
|
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
|
||||||
|
number(row, 1), number(row, 2), number(row, 3), number(row, 4),
|
||||||
|
number(row, 5), number(row, 6)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double number(JsonNode row, int column) {
|
||||||
|
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void failOnError(JsonNode node) throws IOException {
|
||||||
|
if (node.hasNonNull("error")) {
|
||||||
|
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import java.util.Map;
|
|||||||
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
|
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
|
||||||
final class SeriesSources {
|
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 String ELECTRICITY = "electricity";
|
||||||
|
|
||||||
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.application.SnapshotFields.Live;
|
||||||
|
import home.backend.domain.BatteryChange;
|
||||||
|
import home.backend.domain.BatteryResult;
|
||||||
|
import home.backend.domain.BatteryView;
|
||||||
|
import home.backend.domain.BatteryView.Energy;
|
||||||
|
import home.backend.domain.BatteryView.Limits;
|
||||||
|
import home.backend.domain.BatteryView.Mode;
|
||||||
|
import home.backend.domain.BatteryView.Reserve;
|
||||||
|
import home.backend.domain.BatteryView.Slot;
|
||||||
|
import home.backend.domain.GatewaySnapshot;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
|
import static home.backend.application.SnapshotFields.on;
|
||||||
|
import static home.backend.application.SnapshotFields.whole;
|
||||||
|
|
||||||
|
/** F7: the Deye's battery now, and how it is set up. */
|
||||||
|
public class BatteryService {
|
||||||
|
|
||||||
|
/** The settings are read every 10 minutes; three missed reads and they are not current. */
|
||||||
|
private static final Duration SETTINGS_MAX_AGE = Duration.ofMinutes(30);
|
||||||
|
private static final int SLOTS = 6;
|
||||||
|
/** The nominal voltage of a 16-cell lithium pack; the Deye reports capacity in Ah only. */
|
||||||
|
private static final double NOMINAL_VOLTS = 51.2;
|
||||||
|
|
||||||
|
private final GatewayClient gateway;
|
||||||
|
private final GatewayBattery battery;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public BatteryService(GatewayClient gateway, GatewayBattery battery, Clock clock) {
|
||||||
|
this.gateway = gateway;
|
||||||
|
this.battery = battery;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BatteryResult changeSchedule(BatteryChange change, String user) throws IOException {
|
||||||
|
return battery.changeSchedule(change, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BatteryResult changeReserve(int pct, String user) throws IOException {
|
||||||
|
return battery.changeReserve(pct, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asked on its own: a gateway without battery commands still serves the live view. */
|
||||||
|
private Reserve reserve() {
|
||||||
|
try {
|
||||||
|
return battery.reserve();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public BatteryView current() throws IOException {
|
||||||
|
GatewaySnapshot snapshot = gateway.snapshot();
|
||||||
|
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
|
||||||
|
Live settings = SnapshotFields.live(snapshot, "deye-settings", "deye_settings", clock.instant(),
|
||||||
|
SETTINGS_MAX_AGE);
|
||||||
|
Map<String, Double> d = deye.fields();
|
||||||
|
Map<String, Double> s = settings.fields();
|
||||||
|
boolean settingsStale = settings.source().stale();
|
||||||
|
Reserve reserve = reserve();
|
||||||
|
return new BatteryView(deye.source().time(), deye.source().stale(),
|
||||||
|
d.get("battery_soc_pct"), d.get("battery_power_w"), d.get("battery_voltage_v"),
|
||||||
|
d.get("battery_current_a"), d.get("battery_temperature_c"), capacityKwh(s),
|
||||||
|
new Energy(d.get("battery_charge_today_kwh"), d.get("battery_discharge_today_kwh")),
|
||||||
|
new Energy(d.get("battery_charge_total_kwh"), d.get("battery_discharge_total_kwh")),
|
||||||
|
settingsStale ? null : mode(s), settingsStale ? List.of() : schedule(s),
|
||||||
|
settingsStale ? null : limits(s), settings.source().time(), settingsStale, reserve, reserve != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double capacityKwh(Map<String, Double> settings) {
|
||||||
|
Double ah = settings.get("battery_capacity_ah");
|
||||||
|
return ah == null ? null : Math.round(ah * NOMINAL_VOLTS) / 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mode mode(Map<String, Double> s) {
|
||||||
|
boolean tou = on(s, "tou_enabled");
|
||||||
|
return new Mode(tou, tou ? "Harmonogram: bateria pracuje według przedziałów"
|
||||||
|
: "Rezerwa na awarię: bateria nie rozładowuje się na co dzień");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first.
|
||||||
|
* 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";
|
||||||
|
return new Slot(time(whole(s, prefix + "start_hhmm")), time(whole(s, next)),
|
||||||
|
whole(s, prefix + "power_w"), whole(s, prefix + "soc_pct"), on(s, prefix + "grid_charge"));
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String time(int hhmm) {
|
||||||
|
return "%02d:%02d".formatted(hhmm / 100, hhmm % 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Limits limits(Map<String, Double> s) {
|
||||||
|
return new Limits(whole(s, "max_charge_current_a"), whole(s, "max_discharge_current_a"),
|
||||||
|
whole(s, "shutdown_soc_pct"), whole(s, "low_soc_pct"), whole(s, "restart_soc_pct"),
|
||||||
|
on(s, "grid_charge_enabled"), whole(s, "grid_charge_current_a"),
|
||||||
|
whole(s, "grid_charge_start_soc_pct"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One remote answer kept for a while, and no longer than its local day when a zone is given. When a refresh
|
||||||
|
* fails the older answer is served marked stale, and the source is left alone for a short back-off so a
|
||||||
|
* source that is down does not make every request wait for its timeout.
|
||||||
|
*/
|
||||||
|
final class Cached<T> {
|
||||||
|
|
||||||
|
interface Fetch<T> {
|
||||||
|
T fetch() throws IOException;
|
||||||
|
}
|
||||||
|
|
||||||
|
record Entry<T>(T value, boolean stale) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Duration BACK_OFF = Duration.ofSeconds(60);
|
||||||
|
|
||||||
|
private final Duration ttl;
|
||||||
|
private final ZoneId dayZone;
|
||||||
|
private final Clock clock;
|
||||||
|
private final Fetch<T> source;
|
||||||
|
private T value;
|
||||||
|
private Instant fetchedAt;
|
||||||
|
private Instant failedAt;
|
||||||
|
|
||||||
|
Cached(Duration ttl, Clock clock, Fetch<T> source) {
|
||||||
|
this(ttl, null, clock, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param dayZone the answer expires at midnight of this zone even before the ttl, or null */
|
||||||
|
Cached(Duration ttl, ZoneId dayZone, Clock clock, Fetch<T> source) {
|
||||||
|
this.ttl = ttl;
|
||||||
|
this.dayZone = dayZone;
|
||||||
|
this.clock = clock;
|
||||||
|
this.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws IOException when the refresh fails or is backing off, and there is nothing older to serve */
|
||||||
|
synchronized Entry<T> get() throws IOException {
|
||||||
|
Instant now = clock.instant();
|
||||||
|
if (value != null && now.isBefore(expiry())) {
|
||||||
|
return new Entry<>(value, false);
|
||||||
|
}
|
||||||
|
if (failedAt != null && now.isBefore(failedAt.plus(BACK_OFF))) {
|
||||||
|
return older(new IOException("source failed recently, not retrying yet"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
value = source.fetch();
|
||||||
|
fetchedAt = now;
|
||||||
|
failedAt = null;
|
||||||
|
return new Entry<>(value, false);
|
||||||
|
} catch (IOException e) {
|
||||||
|
failedAt = now;
|
||||||
|
return older(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Entry<T> older(IOException failure) throws IOException {
|
||||||
|
if (value == null) {
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
return new Entry<>(value, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant expiry() {
|
||||||
|
Instant byTtl = fetchedAt.plus(ttl);
|
||||||
|
if (dayZone == null) {
|
||||||
|
return byTtl;
|
||||||
|
}
|
||||||
|
Instant midnight = fetchedAt.atZone(dayZone).toLocalDate().plusDays(1).atStartOfDay(dayZone).toInstant();
|
||||||
|
return midnight.isBefore(byTtl) ? midnight : byTtl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.domain.HeatPumpSample;
|
||||||
|
import home.backend.domain.StatsView.Day;
|
||||||
|
import home.backend.domain.StatsView.Totals;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/** What the heat pump did over some span of polls; days are added up into the totals. */
|
||||||
|
final class DayTally {
|
||||||
|
|
||||||
|
private double compressorSeconds;
|
||||||
|
private double cwuSeconds;
|
||||||
|
private double coSeconds;
|
||||||
|
private double heaterSeconds;
|
||||||
|
private int starts;
|
||||||
|
private int samples;
|
||||||
|
private double outdoorSum;
|
||||||
|
private int outdoorCount;
|
||||||
|
private Double minCwu;
|
||||||
|
private Double maxCwu;
|
||||||
|
|
||||||
|
void sample(HeatPumpSample s) {
|
||||||
|
samples++;
|
||||||
|
if (s.outdoorTemp() != null) {
|
||||||
|
outdoorSum += s.outdoorTemp();
|
||||||
|
outdoorCount++;
|
||||||
|
}
|
||||||
|
if (s.cwuTemp() != null) {
|
||||||
|
minCwu = lower(minCwu, s.cwuTemp());
|
||||||
|
maxCwu = higher(maxCwu, s.cwuTemp());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The outputs of {@code s} held for {@code held}. */
|
||||||
|
void hold(HeatPumpSample s, Duration held) {
|
||||||
|
double seconds = held.toMillis() / 1000.0;
|
||||||
|
compressorSeconds += on(s.compressor()) ? seconds : 0;
|
||||||
|
cwuSeconds += on(s.cwu()) ? seconds : 0;
|
||||||
|
coSeconds += on(s.co()) ? seconds : 0;
|
||||||
|
heaterSeconds += on(s.heater()) ? seconds : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
starts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void add(DayTally other) {
|
||||||
|
compressorSeconds += other.compressorSeconds;
|
||||||
|
cwuSeconds += other.cwuSeconds;
|
||||||
|
coSeconds += other.coSeconds;
|
||||||
|
heaterSeconds += other.heaterSeconds;
|
||||||
|
starts += other.starts;
|
||||||
|
samples += other.samples;
|
||||||
|
outdoorSum += other.outdoorSum;
|
||||||
|
outdoorCount += other.outdoorCount;
|
||||||
|
if (other.minCwu != null) {
|
||||||
|
minCwu = lower(minCwu, other.minCwu);
|
||||||
|
maxCwu = higher(maxCwu, other.maxCwu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Day day(LocalDate date) {
|
||||||
|
if (samples == 0) {
|
||||||
|
return new Day(date, null, null, null, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
return new Day(date, hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
||||||
|
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
|
||||||
|
}
|
||||||
|
|
||||||
|
Totals totals() {
|
||||||
|
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
||||||
|
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean on(Double output) {
|
||||||
|
return output != null && output > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double lower(Double known, double value) {
|
||||||
|
return known == null ? value : Math.min(known, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double higher(Double known, double value) {
|
||||||
|
return known == null ? value : Math.max(known, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double avgOutdoor() {
|
||||||
|
return outdoorCount == 0 ? null : Math.round(outdoorSum / outdoorCount * 10) / 10.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double hours(double seconds) {
|
||||||
|
return Math.round(seconds / 3600 * 100) / 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/** Polish name and unit of the Deye program registers and of the app's reserve, as the gateway audits them. */
|
||||||
|
final class DeyeSettingLabels {
|
||||||
|
|
||||||
|
record Label(String what, String unit) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int TOU_ENABLE = 146;
|
||||||
|
private static final int FIRST_START = 148;
|
||||||
|
private static final int FIRST_SOC = 166;
|
||||||
|
private static final int FIRST_GRID_CHARGE = 172;
|
||||||
|
private static final int SLOTS = 6;
|
||||||
|
|
||||||
|
private DeyeSettingLabels() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static Label of(String register) {
|
||||||
|
if ("reserve".equals(register)) {
|
||||||
|
return new Label("Rezerwa baterii", "%");
|
||||||
|
}
|
||||||
|
int address;
|
||||||
|
try {
|
||||||
|
address = Integer.parseInt(register);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return unknown(register);
|
||||||
|
}
|
||||||
|
if (address == TOU_ENABLE) {
|
||||||
|
return new Label("Harmonogram baterii", "0/1");
|
||||||
|
}
|
||||||
|
return slot(address, FIRST_START, "start", "HHMM")
|
||||||
|
.or(() -> slot(address, FIRST_SOC, "poziom", "%"))
|
||||||
|
.or(() -> slot(address, FIRST_GRID_CHARGE, "ładowanie z sieci", "0/1"))
|
||||||
|
.orElseGet(() -> unknown(register));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Label unknown(String register) {
|
||||||
|
return new Label("Rejestr " + register, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<Label> slot(int address, int first, String what, String unit) {
|
||||||
|
return address >= first && address < first + SLOTS
|
||||||
|
? Optional.of(new Label("Przedział " + (address - first + 1) + ": " + what, unit))
|
||||||
|
: Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.application.SnapshotFields.Live;
|
||||||
|
import home.backend.domain.EnergyDetails;
|
||||||
|
import home.backend.domain.EnergyDetails.Device;
|
||||||
|
import home.backend.domain.EnergyDetails.Group;
|
||||||
|
import home.backend.domain.EnergyDetails.Item;
|
||||||
|
import home.backend.domain.GatewaySnapshot;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F7 details: the Deye, Fronius and house meter fields worth a look, grouped and labelled. The
|
||||||
|
* catalog below is the only place that knows a field's label, unit and place; showing a new field
|
||||||
|
* is one line here.
|
||||||
|
*/
|
||||||
|
public class EnergyDetailsService {
|
||||||
|
|
||||||
|
/** A gateway field shown as {@code label}; {@code scale} turns the stored unit into {@code unit}. */
|
||||||
|
private record Field(String key, String label, String unit, int digits, double scale) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Spec(String id, String name, String measurement, List<GroupSpec> groups) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code measurement} is null for the device's own one. */
|
||||||
|
private record GroupSpec(String title, String measurement, List<Field> fields) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each helper returns the fields it stands for, so a group can mix single fields and phase triples.
|
||||||
|
private static Field[] field(String key, String label, String unit, int digits) {
|
||||||
|
return new Field[] {new Field(key, label, unit, digits, 1)};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Field[] kwhFromWh(String key, String label, int digits) {
|
||||||
|
return new Field[] {new Field(key, label, "kWh", digits, 0.001)};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L1 to L3 of one quantity; {@code key} holds %d for the phase number. */
|
||||||
|
private static Field[] phases(String key, String label, String unit, int digits) {
|
||||||
|
return IntStream.rangeClosed(1, 3)
|
||||||
|
.mapToObj(n -> new Field(key.formatted(n), label + " L" + n, unit, digits, 1)).toArray(Field[]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GroupSpec group(String title, Field[]... parts) {
|
||||||
|
return groupOf(null, title, parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group whose fields come from another measurement of the same device. */
|
||||||
|
private static GroupSpec groupOf(String measurement, String title, Field[]... parts) {
|
||||||
|
return new GroupSpec(title, measurement, Arrays.stream(parts).flatMap(Arrays::stream).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final List<Spec> CATALOG = List.of(
|
||||||
|
new Spec("deye", "Falownik Deye", "deye", List.of(
|
||||||
|
group("Bateria",
|
||||||
|
field("battery_soc_pct", "Naładowanie", "%", 0),
|
||||||
|
field("battery_power_w", "Moc", "W", 0),
|
||||||
|
field("battery_voltage_v", "Napięcie", "V", 1),
|
||||||
|
field("battery_current_a", "Prąd", "A", 1),
|
||||||
|
field("battery_temperature_c", "Temperatura", "°C", 1)),
|
||||||
|
group("Sieć",
|
||||||
|
field("grid_power_w", "Moc razem", "W", 0),
|
||||||
|
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",
|
||||||
|
field("load_power_w", "Moc razem", "W", 0),
|
||||||
|
phases("load_power_l%d_w", "Moc", "W", 0)),
|
||||||
|
group("Panele PV",
|
||||||
|
field("pv1_power_w", "PV1 moc", "W", 0),
|
||||||
|
field("pv1_voltage_v", "PV1 napięcie", "V", 1),
|
||||||
|
field("pv1_current_a", "PV1 prąd", "A", 1),
|
||||||
|
field("pv2_power_w", "PV2 moc", "W", 0),
|
||||||
|
field("pv2_voltage_v", "PV2 napięcie", "V", 1),
|
||||||
|
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("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("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),
|
||||||
|
field("battery_discharge_total_kwh", "Bateria rozładowana", "kWh", 0)),
|
||||||
|
group("Falownik",
|
||||||
|
field("inverter_power_w", "Moc wyjściowa", "W", 0),
|
||||||
|
field("inverter_temperature_c", "Temperatura", "°C", 1),
|
||||||
|
field("heatsink_temperature_c", "Radiator", "°C", 1)))),
|
||||||
|
new Spec("fronius", "Falownik Fronius", "fronius", List.of(
|
||||||
|
group("Teraz", field("pv_power_w", "Moc PV", "W", 0)),
|
||||||
|
group("Produkcja",
|
||||||
|
kwhFromWh("energy_day_wh", "Dziś", 1),
|
||||||
|
kwhFromWh("energy_year_wh", "Ten rok", 0),
|
||||||
|
kwhFromWh("energy_total_wh", "Od początku", 0)))),
|
||||||
|
new Spec("house-meter", "Licznik domu", "PRAD2", List.of(
|
||||||
|
group("Moc czynna",
|
||||||
|
field("total_power_active", "Razem", "W", 0),
|
||||||
|
phases("p%d_power_active", "Moc", "W", 0)),
|
||||||
|
group("Napięcie", phases("p%d_voltage", "Napięcie", "V", 1)),
|
||||||
|
group("Prąd", phases("p%d_current", "Prąd", "A", 2)),
|
||||||
|
group("Współczynnik mocy", phases("p%d_power_factor", "cos φ", "", 2)),
|
||||||
|
group("Sieć", field("frequency", "Częstotliwość", "Hz", 2)),
|
||||||
|
group("Energia od początku",
|
||||||
|
field("import_energy_active", "Pobrana", "kWh", 0),
|
||||||
|
field("export_energy_active", "Oddana", "kWh", 0)),
|
||||||
|
groupOf("house_meter_phases", "Energia na fazę",
|
||||||
|
phases("l%d_import_energy_active_kwh", "Pobrana", "kWh", 0),
|
||||||
|
phases("l%d_export_energy_active_kwh", "Oddana", "kWh", 0)))));
|
||||||
|
|
||||||
|
private final GatewayClient gateway;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public EnergyDetailsService(GatewayClient gateway, Clock clock) {
|
||||||
|
this.gateway = gateway;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EnergyDetails current() throws IOException {
|
||||||
|
GatewaySnapshot snapshot = gateway.snapshot();
|
||||||
|
return new EnergyDetails(CATALOG.stream().map(spec -> device(spec, snapshot)).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Device device(Spec spec, GatewaySnapshot snapshot) {
|
||||||
|
Live live = SnapshotFields.live(snapshot, spec.id(), spec.measurement(), clock.instant());
|
||||||
|
List<Group> groups = spec.groups().stream()
|
||||||
|
.map(g -> group(g, g.measurement() == null ? live.fields() : fieldsOf(snapshot, spec.id(), g)))
|
||||||
|
.filter(g -> !g.items().isEmpty()).toList();
|
||||||
|
return new Device(spec.id(), spec.name(), live.source().time(), live.source().stale(), groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Double> fieldsOf(GatewaySnapshot snapshot, String device, GroupSpec group) {
|
||||||
|
return SnapshotFields.live(snapshot, device, group.measurement(), clock.instant()).fields();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Group group(GroupSpec spec, Map<String, Double> fields) {
|
||||||
|
List<Item> items = spec.fields().stream().filter(f -> fields.containsKey(f.key()))
|
||||||
|
.map(f -> new Item(f.label(), scaled(fields.get(f.key()), f), f.unit(), f.digits())).toList();
|
||||||
|
return new Group(spec.title(), items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rounded to the shown precision, so a conversion leaves no binary noise such as 4.3999999. */
|
||||||
|
private static double scaled(double value, Field f) {
|
||||||
|
double factor = Math.pow(10, f.digits());
|
||||||
|
return Math.round(value * f.scale() * factor) / factor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,21 @@
|
|||||||
package home.backend.application;
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.application.SnapshotFields.Live;
|
||||||
import home.backend.domain.EnergyView;
|
import home.backend.domain.EnergyView;
|
||||||
import home.backend.domain.EnergyView.Source;
|
import home.backend.domain.EnergyView.Source;
|
||||||
import home.backend.domain.EnergyView.Today;
|
import home.backend.domain.EnergyView.Today;
|
||||||
import home.backend.domain.GatewaySnapshot;
|
import home.backend.domain.GatewaySnapshot;
|
||||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
|
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
|
||||||
public class EnergyService {
|
public class EnergyService {
|
||||||
|
|
||||||
/** One inverter's fields; empty when it is stale, so nothing old is shown as current. */
|
|
||||||
private record Inverter(Source source, Map<String, Double> fields) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private final GatewayClient gateway;
|
private final GatewayClient gateway;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
@@ -31,8 +26,8 @@ public class EnergyService {
|
|||||||
|
|
||||||
public EnergyView current() throws IOException {
|
public EnergyView current() throws IOException {
|
||||||
GatewaySnapshot snapshot = gateway.snapshot();
|
GatewaySnapshot snapshot = gateway.snapshot();
|
||||||
Inverter deye = inverter(snapshot, "deye");
|
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
|
||||||
Inverter fronius = inverter(snapshot, "fronius");
|
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
|
||||||
Map<String, Double> d = deye.fields();
|
Map<String, Double> d = deye.fields();
|
||||||
Map<String, Double> f = fronius.fields();
|
Map<String, Double> f = fronius.fields();
|
||||||
|
|
||||||
@@ -46,13 +41,6 @@ public class EnergyService {
|
|||||||
d.get("load_power_w"), today);
|
d.get("load_power_w"), today);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Inverter inverter(GatewaySnapshot snapshot, String name) {
|
|
||||||
Optional<Measurement> reading = SnapshotFields.find(snapshot, name, name);
|
|
||||||
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, name), reading, clock.instant());
|
|
||||||
Source source = new Source(name, reading.map(Measurement::time).orElse(null), stale);
|
|
||||||
return new Inverter(source, stale ? Map.of() : reading.get().fields());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Instant oldest(List<Source> sources) {
|
private static Instant oldest(List<Source> sources) {
|
||||||
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.domain.Forecast;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/** The weather forecast service: the past 14 days and the next 3. */
|
||||||
|
public interface ForecastSource {
|
||||||
|
|
||||||
|
Forecast forecast() throws IOException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.domain.BatteryChange;
|
||||||
|
import home.backend.domain.BatteryResult;
|
||||||
|
import home.backend.domain.BatteryView.Reserve;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/** Battery settings through the gateway, which checks every change (ADR-013). */
|
||||||
|
public interface GatewayBattery {
|
||||||
|
|
||||||
|
/** @throws IOException when the gateway has no battery commands, or cannot be reached */
|
||||||
|
Reserve reserve() throws IOException;
|
||||||
|
|
||||||
|
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
|
||||||
|
BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException;
|
||||||
|
|
||||||
|
BatteryResult changeReserve(int pct, String requestedBy) throws IOException;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package home.backend.application;
|
|||||||
|
|
||||||
import home.backend.domain.Command;
|
import home.backend.domain.Command;
|
||||||
import home.backend.domain.CommandResult;
|
import home.backend.domain.CommandResult;
|
||||||
|
import home.backend.domain.RestoreResult;
|
||||||
import home.backend.domain.SetpointState;
|
import home.backend.domain.SetpointState;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -12,5 +13,11 @@ public interface GatewayCommands {
|
|||||||
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
|
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
|
||||||
CommandResult apply(Command command) throws IOException;
|
CommandResult apply(Command command) throws IOException;
|
||||||
|
|
||||||
|
/** Ends the pending change of a setpoint now; a verdict like {@link #apply}. */
|
||||||
|
RestoreResult restore(String setpoint, String requestedBy) throws IOException;
|
||||||
|
|
||||||
SetpointState setpoints() throws IOException;
|
SetpointState setpoints() throws IOException;
|
||||||
|
|
||||||
|
/** Keeps the gateway's live restores alive; returns how many it extended. */
|
||||||
|
int renewLeases() 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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package home.backend.application;
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.domain.GatewaySnapshot;
|
||||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||||
import home.backend.domain.HealthView;
|
import home.backend.domain.HealthView;
|
||||||
import home.backend.domain.HealthView.DeviceHealth;
|
import home.backend.domain.HealthView.DeviceHealth;
|
||||||
@@ -13,19 +14,21 @@ public class HealthService {
|
|||||||
|
|
||||||
private final GatewayClient gateway;
|
private final GatewayClient gateway;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
private final String version;
|
||||||
|
|
||||||
public HealthService(GatewayClient gateway, Clock clock) {
|
public HealthService(GatewayClient gateway, Clock clock, String version) {
|
||||||
this.gateway = gateway;
|
this.gateway = gateway;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
|
this.version = version;
|
||||||
}
|
}
|
||||||
|
|
||||||
public HealthView current() {
|
public HealthView current() {
|
||||||
try {
|
try {
|
||||||
List<DeviceHealth> devices = gateway.snapshot().devices().stream()
|
GatewaySnapshot snapshot = gateway.snapshot();
|
||||||
.map(HealthService::health).toList();
|
List<DeviceHealth> devices = snapshot.devices().stream().map(HealthService::health).toList();
|
||||||
return new HealthView(clock.instant(), true, devices);
|
return new HealthView(clock.instant(), true, devices, version, snapshot.version());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return new HealthView(clock.instant(), false, List.of());
|
return new HealthView(clock.instant(), false, List.of(), version, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.domain.HeatPumpSample;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** The heat pump polls stored in InfluxDB, oldest first. */
|
||||||
|
public interface HeatPumpSamples {
|
||||||
|
|
||||||
|
List<HeatPumpSample> samples(Instant from, Instant to) throws IOException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the gateway now and then that we are still here (ADR-011): while it hears us, our setpoint
|
||||||
|
* changes stay; once we or the tunnel are gone for 15 minutes, it restores them on its own.
|
||||||
|
*/
|
||||||
|
public class LeaseRenewer implements AutoCloseable {
|
||||||
|
|
||||||
|
/** A third of the gateway's lease, so two renewals in a row may fail. */
|
||||||
|
public static final Duration PERIOD = Duration.ofMinutes(5);
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(LeaseRenewer.class);
|
||||||
|
|
||||||
|
private final GatewayCommands gateway;
|
||||||
|
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
|
||||||
|
Thread.ofVirtual().name("lease-renewal").factory());
|
||||||
|
|
||||||
|
public LeaseRenewer(GatewayCommands gateway) {
|
||||||
|
this.gateway = gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
start(PERIOD);
|
||||||
|
}
|
||||||
|
|
||||||
|
void start(Duration period) {
|
||||||
|
scheduler.scheduleWithFixedDelay(this::renew, 0, period.toMillis(), TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A failure is logged and retried next cycle; an exception would end the schedule for good. */
|
||||||
|
void renew() {
|
||||||
|
try {
|
||||||
|
int renewed = gateway.renewLeases();
|
||||||
|
LOGGER.debug("renewed {} lease(s)", renewed);
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
LOGGER.warn("could not renew the gateway leases, retrying in {}: {}", PERIOD, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
scheduler.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.application.SnapshotFields.Live;
|
||||||
|
import home.backend.domain.PiSensorView;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** The BME280 on the Pi, read by the gateway every 5 minutes. */
|
||||||
|
public class PiSensorService {
|
||||||
|
|
||||||
|
private final GatewayClient gateway;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public PiSensorService(GatewayClient gateway, Clock clock) {
|
||||||
|
this.gateway = gateway;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PiSensorView current() throws IOException {
|
||||||
|
Live live = SnapshotFields.live(gateway.snapshot(), "bme280", "MEASUREMENT", clock.instant());
|
||||||
|
Map<String, Double> f = live.fields();
|
||||||
|
return new PiSensorView(live.source().time(), live.source().stale(),
|
||||||
|
f.get("temperature"), f.get("humidity"), f.get("pressure"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** What the panels produced, from InfluxDB. */
|
||||||
|
public interface PvEnergyRepository {
|
||||||
|
|
||||||
|
/** Deye plus Fronius kWh per local day from {@code from} up to {@code to}; days missing either are absent. */
|
||||||
|
Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException;
|
||||||
|
}
|
||||||
@@ -7,15 +7,20 @@ import home.backend.domain.Preset.Change;
|
|||||||
import home.backend.domain.PresetResult;
|
import home.backend.domain.PresetResult;
|
||||||
import home.backend.domain.PresetResult.ChangeResult;
|
import home.backend.domain.PresetResult.ChangeResult;
|
||||||
import home.backend.domain.PresetResult.ChangeStatus;
|
import home.backend.domain.PresetResult.ChangeStatus;
|
||||||
|
import home.backend.domain.RestoreResult;
|
||||||
import home.backend.domain.Setpoint;
|
import home.backend.domain.Setpoint;
|
||||||
import home.backend.domain.SetpointState;
|
import home.backend.domain.SetpointState;
|
||||||
import home.backend.domain.SetpointsView;
|
import home.backend.domain.SetpointsView;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
||||||
public class SetpointService {
|
public class SetpointService {
|
||||||
@@ -38,48 +43,86 @@ public class SetpointService {
|
|||||||
return gateway.apply(new Command(setpoint, value, duration(hours), user));
|
return gateway.apply(new Command(setpoint, value, duration(hours), user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Puts the value from before the pending change back now, instead of waiting for its end. */
|
||||||
|
public RestoreResult restore(String setpoint, String user) throws IOException {
|
||||||
|
return gateway.restore(setpoint, user);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
|
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
|
||||||
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
|
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
|
||||||
|
*
|
||||||
|
* @param until the end the user chose; required by a held preset, not allowed for any other
|
||||||
|
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
|
||||||
|
* preset's value
|
||||||
|
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
|
||||||
|
* for a setpoint the preset does not change or outside the gateway's limits
|
||||||
*/
|
*/
|
||||||
public Optional<PresetResult> applyPreset(String id, String user) throws IOException {
|
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
|
||||||
|
throws IOException {
|
||||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||||
if (preset.isEmpty()) {
|
if (preset.isEmpty()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
List<Change> changes = preset.get().changes();
|
if (preset.get().isHeld() != (until != null)) {
|
||||||
|
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
|
||||||
|
: "preset " + id + " has no end time to choose");
|
||||||
|
}
|
||||||
|
List<Change> changes = withValues(preset.get().changes(), values);
|
||||||
List<Setpoint> limits = gateway.setpoints().setpoints();
|
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, limits)).toList();
|
requireWithinLimits(values, limits);
|
||||||
|
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
|
||||||
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
|
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
|
||||||
return Optional.of(PresetResult.of(checked));
|
return Optional.of(PresetResult.of(checked));
|
||||||
}
|
}
|
||||||
return Optional.of(PresetResult.of(run(changes, user)));
|
return Optional.of(PresetResult.of(run(changes, until, user)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ChangeResult> run(List<Change> changes, String user) {
|
private static List<Change> withValues(List<Change> changes, Map<String, Integer> values) {
|
||||||
|
Set<String> own = changes.stream().map(Change::setpoint).collect(Collectors.toSet());
|
||||||
|
if (!own.containsAll(values.keySet())) {
|
||||||
|
throw new IllegalArgumentException("values may only be for the setpoints the preset changes: " + own);
|
||||||
|
}
|
||||||
|
return changes.stream().map(c -> new Change(c.setpoint(), values.getOrDefault(c.setpoint(), c.value()),
|
||||||
|
c.hours())).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
|
||||||
|
for (Setpoint limit : limits) {
|
||||||
|
Integer value = values.get(limit.name());
|
||||||
|
if (value != null && !limit.allows(value)) {
|
||||||
|
throw new IllegalArgumentException(limit.name() + " must be " + limit.min() + "–" + limit.max()
|
||||||
|
+ " in steps of " + limit.step());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
|
||||||
List<ChangeResult> results = new ArrayList<>();
|
List<ChangeResult> results = new ArrayList<>();
|
||||||
for (Change change : changes) {
|
for (Change change : changes) {
|
||||||
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
|
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
|
||||||
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, user));
|
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, until, user));
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ChangeResult attempt(Change change, String user) {
|
private ChangeResult attempt(Change change, Instant until, String user) {
|
||||||
try {
|
try {
|
||||||
return ChangeResult.of(apply(change.setpoint(), change.value(), change.hours(), user));
|
var command = until == null ? new Command(change.setpoint(), change.value(), duration(change.hours()), user)
|
||||||
|
: new Command(change.setpoint(), change.value(), null, until, user);
|
||||||
|
return ChangeResult.of(gateway.apply(command));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
|
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
|
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
|
||||||
private static ChangeResult check(Change change, List<Setpoint> limits) {
|
private static ChangeResult check(Change change, boolean held, List<Setpoint> limits) {
|
||||||
String problem = problem(change, limits);
|
String problem = problem(change, held, limits);
|
||||||
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
|
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String problem(Change change, List<Setpoint> limits) {
|
private static String problem(Change change, boolean held, List<Setpoint> limits) {
|
||||||
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
|
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
|
||||||
if (known.isEmpty()) {
|
if (known.isEmpty()) {
|
||||||
return "preset sets " + change.setpoint() + ", which the gateway does not know";
|
return "preset sets " + change.setpoint() + ", which the gateway does not know";
|
||||||
@@ -88,6 +131,11 @@ public class SetpointService {
|
|||||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
|
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
|
||||||
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
|
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
|
||||||
}
|
}
|
||||||
|
// The gateway refuses a held raise too; asking first keeps a preset from stopping half way.
|
||||||
|
if (held && known.get().value() != null && change.value() >= known.get().value()) {
|
||||||
|
return "preset sets " + change.setpoint() + " to " + change.value() + ", which is not below the current "
|
||||||
|
+ known.get().value();
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package home.backend.application;
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.domain.EnergyView.Source;
|
||||||
import home.backend.domain.GatewaySnapshot;
|
import home.backend.domain.GatewaySnapshot;
|
||||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||||
@@ -25,8 +26,28 @@ final class SnapshotFields {
|
|||||||
|
|
||||||
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
|
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
|
||||||
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
|
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
|
||||||
|
return stale(device, reading, now, MAX_AGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now,
|
||||||
|
Duration maxAge) {
|
||||||
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|
||||||
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 0;
|
|| Duration.between(reading.get().time(), now).compareTo(maxAge) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One device's fields; empty when it is stale, so nothing old is shown as current. */
|
||||||
|
record Live(Source source, Map<String, Double> fields) {
|
||||||
|
}
|
||||||
|
|
||||||
|
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now) {
|
||||||
|
return live(snapshot, device, measurement, now, MAX_AGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now, Duration maxAge) {
|
||||||
|
Optional<Measurement> reading = find(snapshot, device, measurement);
|
||||||
|
boolean stale = stale(device(snapshot, device), reading, now, maxAge);
|
||||||
|
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
|
||||||
|
return new Live(source, stale ? Map.of() : reading.get().fields());
|
||||||
}
|
}
|
||||||
|
|
||||||
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
|
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.domain.HeatPumpSample;
|
||||||
|
import home.backend.domain.StatsView;
|
||||||
|
import home.backend.domain.StatsView.Day;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F2: the heat pump's work per local day over the last 7 or 30 days. A poll is taken to hold its
|
||||||
|
* outputs until the next poll, but at most {@link #MAX_HOLD}, so a gap in the data adds no hours.
|
||||||
|
*/
|
||||||
|
public class StatsService {
|
||||||
|
|
||||||
|
static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||||
|
static final Duration MAX_HOLD = Duration.ofSeconds(600);
|
||||||
|
private static final Map<String, Integer> RANGE_DAYS = Map.of("7d", 7, "30d", 30);
|
||||||
|
|
||||||
|
private final HeatPumpSamples repository;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public StatsService(HeatPumpSamples repository, Clock clock) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param range {@code 7d} or {@code 30d}
|
||||||
|
* @throws IllegalArgumentException for an unknown range
|
||||||
|
*/
|
||||||
|
public StatsView stats(String range) throws IOException {
|
||||||
|
Integer length = range == null ? null : RANGE_DAYS.get(range);
|
||||||
|
if (length == null) {
|
||||||
|
throw new IllegalArgumentException("unknown range: " + range);
|
||||||
|
}
|
||||||
|
Instant now = clock.instant();
|
||||||
|
LocalDate first = now.atZone(ZONE).toLocalDate().minusDays(length - 1);
|
||||||
|
Map<LocalDate, DayTally> tallies = new TreeMap<>();
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
tallies.put(first.plusDays(i), new DayTally());
|
||||||
|
}
|
||||||
|
tally(repository.samples(first.atStartOfDay(ZONE).toInstant(), now), now, tallies);
|
||||||
|
|
||||||
|
DayTally all = new DayTally();
|
||||||
|
List<Day> days = new ArrayList<>();
|
||||||
|
tallies.forEach((date, tally) -> {
|
||||||
|
days.add(tally.day(date));
|
||||||
|
all.add(tally);
|
||||||
|
});
|
||||||
|
return new StatsView(range, days, all.totals());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void tally(List<HeatPumpSample> samples, Instant now, Map<LocalDate, DayTally> tallies) {
|
||||||
|
HeatPumpSample previous = null;
|
||||||
|
for (int i = 0; i < samples.size(); i++) {
|
||||||
|
HeatPumpSample s = samples.get(i);
|
||||||
|
DayTally day = tallies.get(s.t().atZone(ZONE).toLocalDate());
|
||||||
|
day.sample(s);
|
||||||
|
if (previous != null && previous.compressor() != null && previous.compressor() == 0
|
||||||
|
&& DayTally.on(s.compressor())) {
|
||||||
|
day.start();
|
||||||
|
}
|
||||||
|
previous = s;
|
||||||
|
hold(s, i + 1 < samples.size() ? samples.get(i + 1).t() : now, tallies);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds the time {@code s} was in force to the days it falls in; a stretch over midnight is split. */
|
||||||
|
private static void hold(HeatPumpSample s, Instant next, Map<LocalDate, DayTally> tallies) {
|
||||||
|
Instant end = Duration.between(s.t(), next).compareTo(MAX_HOLD) > 0 ? s.t().plus(MAX_HOLD) : next;
|
||||||
|
Instant at = s.t();
|
||||||
|
while (at.isBefore(end)) {
|
||||||
|
LocalDate date = at.atZone(ZONE).toLocalDate();
|
||||||
|
Instant midnight = date.plusDays(1).atStartOfDay(ZONE).toInstant();
|
||||||
|
Instant stop = midnight.isBefore(end) ? midnight : end;
|
||||||
|
tallies.get(date).hold(s, Duration.between(at, stop));
|
||||||
|
at = stop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.domain.WeatherView.Warning;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Weather warnings that cover the house, whether in force now or announced. */
|
||||||
|
public interface WarningSource {
|
||||||
|
|
||||||
|
List<Warning> warnings() throws IOException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package home.backend.application;
|
||||||
|
|
||||||
|
import home.backend.application.Cached.Entry;
|
||||||
|
import home.backend.domain.Forecast;
|
||||||
|
import home.backend.domain.Forecast.DailyForecast;
|
||||||
|
import home.backend.domain.PvCalibration;
|
||||||
|
import home.backend.domain.WeatherCodes;
|
||||||
|
import home.backend.domain.WeatherView;
|
||||||
|
import home.backend.domain.WeatherView.Day;
|
||||||
|
import home.backend.domain.WeatherView.Now;
|
||||||
|
import home.backend.domain.WeatherView.Warning;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Forecast, PV estimate and warnings, each kept for a while so the phones do not load the sources. */
|
||||||
|
public class WeatherService {
|
||||||
|
|
||||||
|
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||||
|
private static final int CALIBRATION_DAYS = 14;
|
||||||
|
|
||||||
|
private record Calibrated(Forecast forecast, Double factor) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Clock clock;
|
||||||
|
private final PvEnergyRepository pv;
|
||||||
|
private final Cached<Calibrated> forecast;
|
||||||
|
private final Cached<List<Warning>> warnings;
|
||||||
|
|
||||||
|
public WeatherService(ForecastSource forecastSource, WarningSource warningSource, PvEnergyRepository pv,
|
||||||
|
Clock clock) {
|
||||||
|
this.clock = clock;
|
||||||
|
this.pv = pv;
|
||||||
|
this.forecast = new Cached<>(Duration.ofMinutes(30), ZONE, clock, () -> calibrated(forecastSource.forecast()));
|
||||||
|
this.warnings = new Cached<>(Duration.ofMinutes(10), clock, warningSource::warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws IOException when there is no forecast at all; warnings alone may be missing. */
|
||||||
|
public WeatherView current() throws IOException {
|
||||||
|
Entry<Calibrated> weather = forecast.get();
|
||||||
|
Entry<List<Warning>> warned = warnings();
|
||||||
|
LocalDate today = LocalDate.now(clock.withZone(ZONE));
|
||||||
|
Calibrated w = weather.value();
|
||||||
|
return new WeatherView(clock.instant(),
|
||||||
|
day(w, today), day(w, today.plusDays(1)), day(w, today.plusDays(2)),
|
||||||
|
new Now(w.forecast().temperature()), inForce(warned.value()), w.factor(),
|
||||||
|
weather.stale() || warned.stale());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Entry<List<Warning>> warnings() {
|
||||||
|
try {
|
||||||
|
return warnings.get();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return new Entry<>(List.of(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Warning> inForce(List<Warning> all) {
|
||||||
|
return all.stream().filter(w -> w.to().toInstant().isAfter(clock.instant())).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Calibrated calibrated(Forecast fetched) {
|
||||||
|
LocalDate today = LocalDate.now(clock.withZone(ZONE));
|
||||||
|
Map<LocalDate, Double> radiation = new HashMap<>();
|
||||||
|
fetched.days().forEach((date, day) -> {
|
||||||
|
if (day.radiationMj() != null) {
|
||||||
|
radiation.put(date, day.radiationMj());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
var measured = pv.dailyKwh(today.minusDays(CALIBRATION_DAYS), today);
|
||||||
|
return new Calibrated(fetched, PvCalibration.factor(measured, radiation));
|
||||||
|
} catch (IOException e) {
|
||||||
|
return new Calibrated(fetched, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Day day(Calibrated w, LocalDate date) throws IOException {
|
||||||
|
DailyForecast d = w.forecast().days().get(date);
|
||||||
|
if (d == null) {
|
||||||
|
throw new IOException("forecast has no " + date);
|
||||||
|
}
|
||||||
|
Double kwh = w.factor() == null || d.radiationMj() == null ? null : round(w.factor() * d.radiationMj());
|
||||||
|
Double sunshine = d.sunshineSeconds() == null ? null : round(d.sunshineSeconds() / 3600);
|
||||||
|
return new Day(date, d.weatherCode(), WeatherCodes.describe(d.weatherCode()), d.tMax(), d.tMin(),
|
||||||
|
d.precipitationMm(), sunshine, kwh);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double round(double value) {
|
||||||
|
return Math.round(value * 10) / 10.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One audited write. {@code unit} is "°C", "%", "HHMM" (a clock time written as 1830) or "0/1" (off/on);
|
||||||
|
* the values are null when the gateway did not record them.
|
||||||
|
*/
|
||||||
|
public record AuditEntry(Instant time, String source, String what, Integer oldValue, Integer newValue,
|
||||||
|
String unit, String who, String status) {
|
||||||
|
|
||||||
|
public static final String HEAT_PUMP = "heat_pump";
|
||||||
|
public static final String BATTERY = "battery";
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** The audited writes of the heat pump and the battery, newest first. */
|
||||||
|
public record AuditView(List<AuditEntry> entries) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A change of the Time of Use switch and of single program slots (1 to 6); a null field stays as it is.
|
||||||
|
* The gateway checks every rule (ADR-013).
|
||||||
|
*/
|
||||||
|
public record BatteryChange(Boolean enabled, List<Slot> slots) {
|
||||||
|
|
||||||
|
public BatteryChange {
|
||||||
|
slots = slots == null ? List.of() : List.copyOf(slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code from} is the slot's start, to the minute. */
|
||||||
|
public record Slot(int slot, LocalTime from, Integer socPct, Boolean gridCharge) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gateway's verdict on a battery change, passed on unchanged. REJECTED: refused by the rules, nothing
|
||||||
|
* written. STALE: the inverter's settings are unknown or changed meanwhile, nothing written. TOO_SOON: S4.
|
||||||
|
* FAILED: a write or its read-back went wrong; {@code read} has the registers as read back.
|
||||||
|
*/
|
||||||
|
public record BatteryResult(Status status, String message, Map<Integer, Integer> read) {
|
||||||
|
|
||||||
|
public enum Status { APPLIED, REJECTED, STALE, TOO_SOON, FAILED }
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Deye's battery (F7), read-only. Power in W, + discharge / − charge. The live values
|
||||||
|
* ({@code time}, {@code stale}) come from the Deye's readings and are null when stale; the settings
|
||||||
|
* ({@code settingsTime}, {@code settingsStale}: mode, schedule, limits, capacity) come from their own,
|
||||||
|
* slower reading and are null (empty schedule) when stale. The capacity assumes the nominal 51.2 V.
|
||||||
|
* {@code reserve} is the app's backup reserve (ADR-013) and {@code editable} says the gateway accepts
|
||||||
|
* changes; they are null and false when it does not.
|
||||||
|
*/
|
||||||
|
public record BatteryView(Instant time, boolean stale, Double socPct, Double powerW, Double voltageV,
|
||||||
|
Double currentA, Double temperatureC, Double capacityKwh, Energy today, Energy total, Mode mode,
|
||||||
|
List<Slot> schedule, Limits limits, Instant settingsTime, boolean settingsStale, Reserve reserve,
|
||||||
|
boolean editable) {
|
||||||
|
|
||||||
|
/** The share of the battery kept for a power cut, and the lowest the user may set. */
|
||||||
|
public record Reserve(int pct, int floorPct) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Battery energy in kWh. */
|
||||||
|
public record Energy(Double chargeKwh, Double dischargeKwh) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time of use on means the battery follows the schedule; off means backup only. */
|
||||||
|
public record Mode(boolean touEnabled, String description) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A program slot, {@code from} and {@code to} as HH:MM; it runs until the next slot starts. */
|
||||||
|
public record Slot(String from, String to, int powerW, int socPct, boolean gridCharge) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Limits(Integer maxChargeA, Integer maxDischargeA, Integer shutdownSocPct,
|
||||||
|
Integer lowSocPct, Integer restartSocPct, boolean gridChargeEnabled, Integer gridChargeCurrentA,
|
||||||
|
Integer gridChargeStartSocPct) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
package home.backend.domain;
|
package home.backend.domain;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
/** Set a setpoint; with a duration the gateway restores the old value afterwards, else it stays. */
|
/**
|
||||||
public record Command(String setpoint, int value, Duration duration, String requestedBy) {
|
* Set a setpoint. With a duration the gateway restores the old value afterwards; with
|
||||||
|
* {@code heldUntil} it holds a lowering value until then without renewals (ADR-012); else it stays
|
||||||
|
* while the backend renews the lease (ADR-011).
|
||||||
|
*/
|
||||||
|
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
|
||||||
|
|
||||||
|
public Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||||
|
this(setpoint, value, duration, null, requestedBy);
|
||||||
|
}
|
||||||
|
|
||||||
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
|
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
|
||||||
public static final int MAX_HOURS = 72;
|
public static final int MAX_HOURS = 72;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every value the energy devices report, grouped for a details panel (F7). A device without a
|
||||||
|
* fresh reading has {@code stale} true and no groups; a field the device did not report is absent.
|
||||||
|
*/
|
||||||
|
public record EnergyDetails(List<Device> devices) {
|
||||||
|
|
||||||
|
/** One device; {@code id} is the gateway's device name. */
|
||||||
|
public record Device(String id, String name, Instant time, boolean stale, List<Group> groups) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Group(String title, List<Item> items) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A value in {@code unit}, to be shown with {@code digits} decimals. */
|
||||||
|
public record Item(String label, double value, String unit, int digits) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@ import java.time.Instant;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/** The gateway's latest state of every device, as the backend sees it. */
|
/** The gateway's latest state of every device, as the backend sees it.
|
||||||
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
|
* The version is the gateway's release tag, null for a gateway that does not report it yet. */
|
||||||
|
public record GatewaySnapshot(String version, List<DeviceSnapshot> devices) {
|
||||||
|
|
||||||
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
|
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
|
||||||
List<Measurement> measurements) {
|
List<Measurement> measurements) {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package home.backend.domain;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** Health of the chain phone → backend → gateway → devices (F5). */
|
/** Health of the chain phone → backend → gateway → devices (F5), with the deployed versions.
|
||||||
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
|
* The gateway's version is null when it is unreachable or too old to report it. */
|
||||||
|
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices,
|
||||||
|
String backendVersion, String gatewayVersion) {
|
||||||
|
|
||||||
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
|
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
|
||||||
|
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co, Double heater,
|
||||||
|
Double outdoorTemp, Double cwuTemp) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The BME280 sensor on the Raspberry Pi: °C, % and hPa. When it is stale (device down, no reading
|
||||||
|
* or older than 2 × 300 s) the values are null and {@code stale} is true; {@code time} is null
|
||||||
|
* without any reading.
|
||||||
|
*/
|
||||||
|
public record PiSensorView(Instant time, boolean stale, Double temperature, Double humidity, Double pressure) {
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
package home.backend.domain;
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** A named one-tap bundle of setpoint changes (F3), e.g. boost or away. */
|
/**
|
||||||
public record Preset(String id, String name, List<Change> changes) {
|
* A named one-tap bundle of setpoint changes (F3), e.g. boost or away. {@code until} is null, or
|
||||||
|
* {@link #CHOSEN}: the user picks the end on a calendar and the gateway holds the changes until
|
||||||
|
* then without renewals (ADR-012).
|
||||||
|
*/
|
||||||
|
public record Preset(String id, String name, @JsonInclude(JsonInclude.Include.NON_NULL) String until,
|
||||||
|
List<Change> changes) {
|
||||||
|
|
||||||
|
public static final String CHOSEN = "chosen";
|
||||||
|
|
||||||
/** {@code hours} null means "until changed back". */
|
/** {@code hours} null means "until changed back". */
|
||||||
public record Change(String setpoint, int value, Double hours) {
|
public record Change(String setpoint, int value, Double hours) {
|
||||||
@@ -18,6 +28,11 @@ public record Preset(String id, String name, List<Change> changes) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isHeld() {
|
||||||
|
return CHOSEN.equals(until);
|
||||||
|
}
|
||||||
|
|
||||||
public Preset {
|
public Preset {
|
||||||
if (id == null || id.isBlank() || name == null || name.isBlank()) {
|
if (id == null || id.isBlank() || name == null || name.isBlank()) {
|
||||||
throw new IllegalArgumentException("a preset needs an id and a name");
|
throw new IllegalArgumentException("a preset needs an id and a name");
|
||||||
@@ -25,5 +40,11 @@ public record Preset(String id, String name, List<Change> changes) {
|
|||||||
if (changes == null || changes.isEmpty()) {
|
if (changes == null || changes.isEmpty()) {
|
||||||
throw new IllegalArgumentException("preset " + id + " has no changes");
|
throw new IllegalArgumentException("preset " + id + " has no changes");
|
||||||
}
|
}
|
||||||
|
if (until != null && !until.equals(CHOSEN)) {
|
||||||
|
throw new IllegalArgumentException("preset " + id + ": until can only be \"" + CHOSEN + "\"");
|
||||||
|
}
|
||||||
|
if (CHOSEN.equals(until) && changes.stream().anyMatch(change -> change.hours() != null)) {
|
||||||
|
throw new IllegalArgumentException("preset " + id + " is held until a chosen time, so no hours");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PV output per unit of radiation, found from past days instead of the panels' tilt and azimuth:
|
||||||
|
* measured kWh over forecast MJ/m², summed over the days that have both.
|
||||||
|
*/
|
||||||
|
public final class PvCalibration {
|
||||||
|
|
||||||
|
static final int MIN_DAYS = 5;
|
||||||
|
|
||||||
|
private PvCalibration() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null with fewer than {@value MIN_DAYS} days having both values or no radiation at all. */
|
||||||
|
public static Double factor(Map<LocalDate, Double> measuredKwh, Map<LocalDate, Double> radiationMj) {
|
||||||
|
double kwh = 0;
|
||||||
|
double mj = 0;
|
||||||
|
int days = 0;
|
||||||
|
for (var day : measuredKwh.entrySet()) {
|
||||||
|
Double radiation = radiationMj.get(day.getKey());
|
||||||
|
if (radiation != null) {
|
||||||
|
kwh += day.getValue();
|
||||||
|
mj += radiation;
|
||||||
|
days++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return days < MIN_DAYS || mj <= 0 ? null : kwh / mj;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package home.backend.domain;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
/** A boost the gateway will undo at {@code due}, unless someone changed the register meanwhile. */
|
/**
|
||||||
|
* A change the gateway will undo at {@code due} (null: until changed), unless someone changed the
|
||||||
|
* register meanwhile. It also undoes it when we stop renewing the lease (ADR-011).
|
||||||
|
*/
|
||||||
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
|
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
/** The gateway's answer to "restore now", passed on to the phone unchanged. */
|
||||||
|
public record RestoreResult(Outcome outcome, String message) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOT_PENDING: nothing to restore. CONFLICT: the register was changed meanwhile, so it was left alone.
|
||||||
|
* TOO_SOON: the gateway's rate limit. FAILED: the restore stays pending and is retried.
|
||||||
|
*/
|
||||||
|
public enum Outcome { RESTORED, NOT_PENDING, CONFLICT, TOO_SOON, FAILED }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heat pump work per local day (F2). Hours are approximated from the polls, starts are counted
|
||||||
|
* between polls, so short cycles are missed. A day without polls has null values.
|
||||||
|
*/
|
||||||
|
public record StatsView(String range, List<Day> days, Totals totals) {
|
||||||
|
|
||||||
|
public record Day(LocalDate date, Double compressorHours, Integer compressorStarts, Double cwuHours,
|
||||||
|
Double coHours, Double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Totals(double compressorHours, int compressorStarts, double cwuHours, double coHours,
|
||||||
|
double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
/** WMO weather interpretation codes as Polish text. */
|
||||||
|
public final class WeatherCodes {
|
||||||
|
|
||||||
|
private WeatherCodes() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String describe(Integer code) {
|
||||||
|
if (code == null) {
|
||||||
|
return "Brak danych";
|
||||||
|
}
|
||||||
|
return switch (code) {
|
||||||
|
case 0 -> "Bezchmurnie";
|
||||||
|
case 1 -> "Przeważnie słonecznie";
|
||||||
|
case 2 -> "Częściowe zachmurzenie";
|
||||||
|
case 3 -> "Pochmurno";
|
||||||
|
case 45, 48 -> "Mgła";
|
||||||
|
case 51, 53, 55 -> "Mżawka";
|
||||||
|
case 56, 57 -> "Marznąca mżawka";
|
||||||
|
case 61 -> "Słaby deszcz";
|
||||||
|
case 63 -> "Deszcz";
|
||||||
|
case 65 -> "Silny deszcz";
|
||||||
|
case 66, 67 -> "Marznący deszcz";
|
||||||
|
case 71 -> "Słaby śnieg";
|
||||||
|
case 73 -> "Śnieg";
|
||||||
|
case 75 -> "Intensywny śnieg";
|
||||||
|
case 77 -> "Ziarna śnieżne";
|
||||||
|
case 80, 81 -> "Przelotny deszcz";
|
||||||
|
case 82 -> "Ulewa";
|
||||||
|
case 85, 86 -> "Przelotny śnieg";
|
||||||
|
case 95 -> "Burza";
|
||||||
|
case 96, 99 -> "Burza z gradem";
|
||||||
|
default -> "Brak danych";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package home.backend.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weather at the house: three days, the temperature now and the IMGW warnings in force or announced.
|
||||||
|
* {@code pvFactor} (kWh per MJ/m² of forecast radiation) and every {@code pvForecastKwh} are null while
|
||||||
|
* there are too few measured days to calibrate. {@code stale} means part of it is an older answer
|
||||||
|
* because a source could not be reached.
|
||||||
|
*/
|
||||||
|
public record WeatherView(Instant time, Day today, Day tomorrow, Day dayAfter, Now now,
|
||||||
|
List<Warning> warnings, Double pvFactor, boolean stale) {
|
||||||
|
|
||||||
|
public record Day(LocalDate date, Integer weatherCode, String description, Double tMax, Double tMin,
|
||||||
|
Double precipitationMm, Double sunshineHours, Double pvForecastKwh) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Now(Double temperature) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code level} is IMGW's degree 1 to 3. */
|
||||||
|
public record Warning(String event, int level, OffsetDateTime from, OffsetDateTime to, String text) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
{
|
{
|
||||||
"id": "away",
|
"id": "away",
|
||||||
"name": "Wyjazd",
|
"name": "Wyjazd",
|
||||||
|
"until": "chosen",
|
||||||
"changes": [
|
"changes": [
|
||||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||||
|
|||||||
@@ -0,0 +1,672 @@
|
|||||||
|
package home.backend;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import home.backend.adapters.auth.AuthSettings;
|
||||||
|
import home.backend.adapters.auth.OidcSettings;
|
||||||
|
import home.backend.adapters.auth.PasswordHash;
|
||||||
|
import home.backend.adapters.auth.StubIssuer;
|
||||||
|
import home.backend.adapters.http.ApiServer;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Sign-in end to end: local accounts and OIDC against a stub issuer, behind the real HTTP server. */
|
||||||
|
class AuthApiTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
private static final String SIGNING_KEY = "0123456789abcdef0123456789abcdef-test";
|
||||||
|
private static final String PASSWORD = "correct horse";
|
||||||
|
private static final String LOGIN = "{\"username\":\"tata\",\"password\":\"" + PASSWORD + "\"}";
|
||||||
|
private static final String CALLBACK = "https://pompa.example/api/auth/oidc/callback";
|
||||||
|
private static String hash;
|
||||||
|
|
||||||
|
private final HttpClient client = HttpClient.newHttpClient();
|
||||||
|
private final List<ApiServer> servers = new ArrayList<>();
|
||||||
|
private final List<HttpServer> stubs = new ArrayList<>();
|
||||||
|
private final List<String> requestedBy = new ArrayList<>();
|
||||||
|
private StubIssuer issuer;
|
||||||
|
private ApiServer backend;
|
||||||
|
private HttpServer gateway;
|
||||||
|
@TempDir
|
||||||
|
Path dir;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void hashOnce() {
|
||||||
|
hash = PasswordHash.create(PASSWORD.toCharArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stop() {
|
||||||
|
servers.forEach(ApiServer::stop);
|
||||||
|
stubs.forEach(s -> s.stop(0));
|
||||||
|
if (issuer != null) {
|
||||||
|
issuer.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- set-up ---
|
||||||
|
|
||||||
|
private void launch(boolean local, boolean oidc, String role) throws IOException {
|
||||||
|
issuer = new StubIssuer();
|
||||||
|
launch(local ? users() : null, oidc ? oidcSettings(issuer.issuer(), role) : null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path users() throws IOException {
|
||||||
|
return Files.writeString(dir.resolve("users.json"),
|
||||||
|
"[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OidcSettings oidcSettings(URI issuer, String role) {
|
||||||
|
return new OidcSettings(issuer, StubIssuer.CLIENT_ID, StubIssuer.CLIENT_KEY, URI.create(CALLBACK), role);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void launch(Path usersFile, OidcSettings oidc, boolean disabled) throws IOException {
|
||||||
|
gateway = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||||
|
gateway.createContext("/commands", this::commands);
|
||||||
|
gateway.start();
|
||||||
|
stubs.add(gateway);
|
||||||
|
URI dead = URI.create("http://127.0.0.1:1");
|
||||||
|
var settings = new Settings(URI.create("http://127.0.0.1:" + gateway.getAddress().getPort()), dead, 0,
|
||||||
|
Duration.ofSeconds(2), Duration.ofSeconds(2), "s3cret-token", null,
|
||||||
|
new AuthSettings(usersFile, oidc, SIGNING_KEY, 30, disabled, 1));
|
||||||
|
backend = Backend.assemble(settings, Clock.systemUTC());
|
||||||
|
servers.add(backend);
|
||||||
|
backend.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void commands(HttpExchange exchange) throws IOException {
|
||||||
|
boolean post = exchange.getRequestMethod().equals("POST");
|
||||||
|
if (post) {
|
||||||
|
requestedBy.add(exchange.getRequestHeaders().getFirst("X-Requested-By"));
|
||||||
|
}
|
||||||
|
byte[] body = Files.readAllBytes(Path.of("../gateway/src/test/resources/commands/"
|
||||||
|
+ (post ? "response-applied.json" : "setpoints.json")));
|
||||||
|
exchange.sendResponseHeaders(200, body.length);
|
||||||
|
exchange.getResponseBody().write(body);
|
||||||
|
exchange.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- requests ---
|
||||||
|
|
||||||
|
private HttpResponse<String> send(String method, String path, String body, String... headers) throws Exception {
|
||||||
|
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
|
||||||
|
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
|
||||||
|
: HttpRequest.BodyPublishers.ofString(body));
|
||||||
|
if (headers.length > 0) {
|
||||||
|
request.headers(headers);
|
||||||
|
}
|
||||||
|
return client.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpResponse<String> get(String path, String... headers) throws Exception {
|
||||||
|
return send("GET", path, null, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpResponse<String> post(String path, String body, String... headers) throws Exception {
|
||||||
|
var all = new ArrayList<>(List.of("Content-Type", "application/json", "X-Requested-With", "home-pump"));
|
||||||
|
all.addAll(List.of(headers));
|
||||||
|
return send("POST", path, body, all.toArray(String[]::new));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String cookie(HttpResponse<?> response, String name) {
|
||||||
|
return response.headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(name + "="))
|
||||||
|
.findFirst().map(c -> c.split(";")[0]).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String setCookie(HttpResponse<?> response, String name) {
|
||||||
|
return response.headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(name + "="))
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String signIn() throws Exception {
|
||||||
|
return cookie(post("/api/auth/login", LOGIN), "hp_session");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode json(HttpResponse<String> response) throws IOException {
|
||||||
|
return JSON.readTree(response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode example(String name) throws IOException {
|
||||||
|
return JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + name + ".json")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- what is open and what is not ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void configTellsWhichSignInsExist() throws Exception {
|
||||||
|
launch(true, true, null);
|
||||||
|
|
||||||
|
assertEquals(example("auth-config"), json(get("/api/auth/config")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void everythingElseNeedsASession() throws Exception {
|
||||||
|
launch(true, true, null);
|
||||||
|
|
||||||
|
for (String path : List.of("/api/health", "/api/heat-pump", "/api/setpoints", "/api/audit", "/api/weather")) {
|
||||||
|
var response = get(path);
|
||||||
|
assertEquals(401, response.statusCode(), path);
|
||||||
|
assertEquals("{\"error\":\"unauthenticated\"}", response.body(), path);
|
||||||
|
}
|
||||||
|
assertEquals(401, post("/api/setpoints", "{}").statusCode());
|
||||||
|
assertEquals(401, get("/api/auth/me").statusCode());
|
||||||
|
assertEquals(200, get("/api/live").statusCode());
|
||||||
|
assertEquals("ok", get("/api/live").body());
|
||||||
|
assertEquals(404, get("/api/unknown").statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sessionCookieOpensTheApiAndNamesTheRequester() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
String session = signIn();
|
||||||
|
|
||||||
|
assertEquals(200, get("/api/health", "Cookie", session).statusCode());
|
||||||
|
assertEquals(200, post("/api/setpoints", "{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":2}",
|
||||||
|
"Cookie", session, "Remote-User", "intruder").statusCode());
|
||||||
|
assertEquals(List.of("tata"), requestedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forgedSessionCookiesAreRefused() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
String session = signIn();
|
||||||
|
|
||||||
|
assertEquals(401, get("/api/health", "Cookie", session + "x").statusCode());
|
||||||
|
assertEquals(401, get("/api/health", "Cookie", "hp_session=" + session.split("\\.")[0].split("=")[1]).statusCode());
|
||||||
|
assertEquals(401, get("/api/auth/me", "Cookie", "hp_session=eyJ1c2VyIjoiYWRtaW4ifQ.AAAA").statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- local login ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void localLoginSetsAHardenedCookieAndMeAnswers() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
|
||||||
|
var login = post("/api/auth/login", LOGIN);
|
||||||
|
|
||||||
|
assertEquals(200, login.statusCode());
|
||||||
|
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"local\"}"), json(login));
|
||||||
|
String set = setCookie(login, "hp_session");
|
||||||
|
for (String flag : new String[] {"HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=2592000"}) {
|
||||||
|
assertTrue(set.contains(flag), flag);
|
||||||
|
}
|
||||||
|
var me = get("/api/auth/me", "Cookie", cookie(login, "hp_session"));
|
||||||
|
assertEquals(200, me.statusCode());
|
||||||
|
assertEquals(example("auth-me"), json(me));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrongPasswordAndUnknownUserLookAlike() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
|
||||||
|
for (String body : List.of("{\"username\":\"tata\",\"password\":\"nope\"}",
|
||||||
|
"{\"username\":\"ghost\",\"password\":\"" + PASSWORD + "\"}")) {
|
||||||
|
var response = post("/api/auth/login", body);
|
||||||
|
assertEquals(401, response.statusCode());
|
||||||
|
assertEquals("{\"error\":\"invalid\"}", response.body());
|
||||||
|
assertEquals(null, setCookie(response, "hp_session"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fiveFailuresThrottleTheUserAndTheAddressEvenForTheRightPassword() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
String wrong = "{\"username\":\"tata\",\"password\":\"nope\"}";
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
assertEquals(401, post("/api/auth/login", wrong, "X-Forwarded-For", "1.1.1." + i + ", 7.7.7.7").statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = post("/api/auth/login", LOGIN, "X-Forwarded-For", "8.8.8.8");
|
||||||
|
var address = post("/api/auth/login", "{\"username\":\"ghost\",\"password\":\"x\"}",
|
||||||
|
"X-Forwarded-For", "2.2.2.2, 7.7.7.7");
|
||||||
|
var other = post("/api/auth/login", "{\"username\":\"ghost\",\"password\":\"x\"}",
|
||||||
|
"X-Forwarded-For", "2.2.2.2, 8.8.8.8");
|
||||||
|
|
||||||
|
assertEquals(429, user.statusCode());
|
||||||
|
assertEquals("throttled", json(user).get("error").asText());
|
||||||
|
int wait = json(user).get("retryAfterSeconds").asInt();
|
||||||
|
assertTrue(wait > 14 * 60 && wait <= 15 * 60 + 1, "retry after " + wait);
|
||||||
|
assertEquals(String.valueOf(wait), user.headers().firstValue("Retry-After").orElseThrow());
|
||||||
|
assertEquals(429, address.statusCode());
|
||||||
|
assertEquals(401, other.statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loginAndLogoutKeepTheWriteGuards() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
|
||||||
|
assertEquals(403, post("/api/auth/login", LOGIN, "Origin", "https://evil.example").statusCode());
|
||||||
|
assertEquals(403, send("POST", "/api/auth/login", LOGIN, "Content-Type", "application/json").statusCode());
|
||||||
|
assertEquals(415, send("POST", "/api/auth/login", LOGIN, "X-Requested-With", "home-pump",
|
||||||
|
"Content-Type", "text/plain").statusCode());
|
||||||
|
assertEquals(400, post("/api/auth/login", "{}").statusCode());
|
||||||
|
assertEquals(400, post("/api/auth/login", "not json").statusCode());
|
||||||
|
assertEquals(403, send("POST", "/api/auth/logout", "{}", "Content-Type", "application/json").statusCode());
|
||||||
|
assertEquals(405, get("/api/auth/login").statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sessionOfARemovedAccountStopsWorking() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
String session = signIn();
|
||||||
|
assertEquals(200, get("/api/health", "Cookie", session).statusCode());
|
||||||
|
|
||||||
|
Files.writeString(dir.resolve("users.json"), "[{\"username\":\"mama\",\"passwordHash\":\"" + hash + "\"}]");
|
||||||
|
launch(dir.resolve("users.json"), null, false);
|
||||||
|
|
||||||
|
assertEquals(401, get("/api/health", "Cookie", session).statusCode());
|
||||||
|
assertEquals(401, get("/api/auth/me", "Cookie", session).statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parallelWrongPasswordsAreEvaluatedAtMostFiveTimes() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
var pool = java.util.concurrent.Executors.newFixedThreadPool(40);
|
||||||
|
var futures = new ArrayList<java.util.concurrent.Future<Integer>>();
|
||||||
|
for (int i = 0; i < 40; i++) {
|
||||||
|
futures.add(pool.submit(() -> post("/api/auth/login", "{\"username\":\"tata\",\"password\":\"nope\"}")
|
||||||
|
.statusCode()));
|
||||||
|
}
|
||||||
|
int evaluated = 0;
|
||||||
|
for (var future : futures) {
|
||||||
|
if (future.get() == 401) {
|
||||||
|
evaluated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pool.shutdown();
|
||||||
|
|
||||||
|
assertTrue(evaluated >= 1 && evaluated <= 5, "evaluated " + evaluated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- first account and account management ---
|
||||||
|
|
||||||
|
private static final String NEW_PASSWORD = "a brand new password";
|
||||||
|
|
||||||
|
private static String account(String name, String password) {
|
||||||
|
return "{\"username\":\"" + name + "\",\"password\":\"" + password + "\"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void launchInSetupMode() throws IOException {
|
||||||
|
launch(dir.resolve("auth-users.json"), null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setupCreatesTheFirstAccountOnceAndSignsItIn() throws Exception {
|
||||||
|
launchInSetupMode();
|
||||||
|
assertEquals(JSON.readTree("{\"local\":true,\"oidc\":false,\"setup\":true}"), json(get("/api/auth/config")));
|
||||||
|
|
||||||
|
var created = post("/api/auth/setup", account("tata", NEW_PASSWORD));
|
||||||
|
|
||||||
|
assertEquals(200, created.statusCode());
|
||||||
|
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"local\"}"), json(created));
|
||||||
|
assertEquals(200, get("/api/health", "Cookie", cookie(created, "hp_session")).statusCode());
|
||||||
|
assertEquals(JSON.readTree("{\"local\":true,\"oidc\":false,\"setup\":false}"), json(get("/api/auth/config")));
|
||||||
|
var second = post("/api/auth/setup", account("mama", NEW_PASSWORD));
|
||||||
|
assertEquals(409, second.statusCode());
|
||||||
|
assertEquals("{\"error\":\"setup_done\"}", second.body());
|
||||||
|
assertEquals(200, post("/api/auth/login", account("tata", NEW_PASSWORD)).statusCode());
|
||||||
|
assertEquals(401, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setupWithAWeakPasswordOrOddNameIsRefusedAndCreatesNothing() throws Exception {
|
||||||
|
launchInSetupMode();
|
||||||
|
|
||||||
|
var weak = post("/api/auth/setup", account("tata", "short"));
|
||||||
|
var odd = post("/api/auth/setup", account("a b", NEW_PASSWORD));
|
||||||
|
|
||||||
|
assertEquals(400, weak.statusCode());
|
||||||
|
assertEquals("{\"error\":\"weak_password\"}", weak.body());
|
||||||
|
assertEquals(400, odd.statusCode());
|
||||||
|
assertEquals(true, json(get("/api/auth/config")).get("setup").asBoolean());
|
||||||
|
assertFalse(Files.exists(dir.resolve("auth-users.json")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setupKeepsTheWriteGuards() throws Exception {
|
||||||
|
launchInSetupMode();
|
||||||
|
|
||||||
|
assertEquals(403, post("/api/auth/setup", account("tata", NEW_PASSWORD), "Origin", "https://evil.example")
|
||||||
|
.statusCode());
|
||||||
|
assertEquals(403, send("POST", "/api/auth/setup", account("tata", NEW_PASSWORD), "Content-Type",
|
||||||
|
"application/json").statusCode());
|
||||||
|
assertEquals(415, send("POST", "/api/auth/setup", account("tata", NEW_PASSWORD), "X-Requested-With",
|
||||||
|
"home-pump", "Content-Type", "text/plain").statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void setupIsNotFoundWithoutLocalAccounts() throws Exception {
|
||||||
|
launch(false, true, null);
|
||||||
|
|
||||||
|
assertEquals(404, post("/api/auth/setup", account("tata", NEW_PASSWORD)).statusCode());
|
||||||
|
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":true,\"setup\":false}"), json(get("/api/auth/config")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twoParallelSetupsLeaveExactlyOneWinner() throws Exception {
|
||||||
|
launchInSetupMode();
|
||||||
|
var pool = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||||
|
var a = pool.submit(() -> post("/api/auth/setup", account("tata", NEW_PASSWORD)).statusCode());
|
||||||
|
var b = pool.submit(() -> post("/api/auth/setup", account("mama", NEW_PASSWORD)).statusCode());
|
||||||
|
var codes = new java.util.TreeSet<>(List.of(a.get(), b.get()));
|
||||||
|
pool.shutdown();
|
||||||
|
|
||||||
|
assertEquals(new java.util.TreeSet<>(List.of(200, 409)), codes);
|
||||||
|
assertEquals(1, JSON.readTree(Files.readString(dir.resolve("auth-users.json"))).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void accountManagementNeedsASession() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
|
||||||
|
assertEquals(401, get("/api/auth/users").statusCode());
|
||||||
|
assertEquals(401, post("/api/auth/users", account("mama", NEW_PASSWORD)).statusCode());
|
||||||
|
assertEquals(401, post("/api/auth/password", "{\"current\":\"x\",\"new\":\"" + NEW_PASSWORD + "\"}")
|
||||||
|
.statusCode());
|
||||||
|
assertEquals(401, post("/api/auth/users/tata/delete", "{}").statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usersCanBeListedAddedAndRemovedButNotTheLastOne() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
String session = signIn();
|
||||||
|
|
||||||
|
assertEquals(JSON.readTree("[{\"username\":\"tata\"}]"), json(get("/api/auth/users", "Cookie", session)));
|
||||||
|
assertEquals(200, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
|
||||||
|
assertEquals(409, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
|
||||||
|
assertEquals(400, post("/api/auth/users", account("zosia", "short"), "Cookie", session).statusCode());
|
||||||
|
assertEquals(2, json(get("/api/auth/users", "Cookie", session)).size());
|
||||||
|
assertEquals(200, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
|
||||||
|
|
||||||
|
assertEquals(204, post("/api/auth/users/mama/delete", "{}", "Cookie", session).statusCode());
|
||||||
|
assertEquals(404, post("/api/auth/users/mama/delete", "{}", "Cookie", session).statusCode());
|
||||||
|
var last = post("/api/auth/users/tata/delete", "{}", "Cookie", session);
|
||||||
|
assertEquals(409, last.statusCode());
|
||||||
|
assertEquals("{\"error\":\"last_user\"}", last.body());
|
||||||
|
assertEquals(403, send("POST", "/api/auth/users/mama/delete", "{}", "Cookie", session,
|
||||||
|
"Content-Type", "application/json").statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anOidcSessionMayManageAccountsButNotChangeAPassword() throws Exception {
|
||||||
|
launch(true, true, null);
|
||||||
|
Started started = start();
|
||||||
|
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
|
||||||
|
String session = cookie(callbackWithCode(started), "hp_session");
|
||||||
|
|
||||||
|
assertEquals(200, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
|
||||||
|
assertEquals(403, post("/api/auth/password", "{\"current\":\"x\",\"new\":\"" + NEW_PASSWORD + "\"}",
|
||||||
|
"Cookie", session).statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removedAccountLosesItsSessionAtOnceAndRemovingYourselfSignsYouOut() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
String tata = signIn();
|
||||||
|
post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", tata);
|
||||||
|
String mama = cookie(post("/api/auth/login", account("mama", NEW_PASSWORD)), "hp_session");
|
||||||
|
assertEquals(200, get("/api/health", "Cookie", mama).statusCode());
|
||||||
|
|
||||||
|
assertEquals(204, post("/api/auth/users/mama/delete", "{}", "Cookie", tata).statusCode());
|
||||||
|
assertEquals(401, get("/api/health", "Cookie", mama).statusCode());
|
||||||
|
|
||||||
|
post("/api/auth/users", account("zosia", NEW_PASSWORD), "Cookie", tata);
|
||||||
|
var self = post("/api/auth/users/tata/delete", "{}", "Cookie", tata);
|
||||||
|
assertEquals(204, self.statusCode());
|
||||||
|
assertTrue(setCookie(self, "hp_session").startsWith("hp_session=; Max-Age=0"));
|
||||||
|
assertEquals(401, get("/api/health", "Cookie", tata).statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ownPasswordChangeNeedsTheCurrentOneAndCountsFailuresInTheThrottle() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
String session = signIn();
|
||||||
|
String wrong = "{\"current\":\"nope\",\"new\":\"" + NEW_PASSWORD + "\"}";
|
||||||
|
|
||||||
|
assertEquals(400, post("/api/auth/password", "{\"current\":\"" + PASSWORD + "\",\"new\":\"short\"}",
|
||||||
|
"Cookie", session).statusCode());
|
||||||
|
var rejected = post("/api/auth/password", wrong, "Cookie", session);
|
||||||
|
assertEquals(401, rejected.statusCode());
|
||||||
|
assertEquals("{\"error\":\"invalid\"}", rejected.body());
|
||||||
|
assertEquals(204, post("/api/auth/password", "{\"current\":\"" + PASSWORD + "\",\"new\":\""
|
||||||
|
+ NEW_PASSWORD + "\"}", "Cookie", session).statusCode());
|
||||||
|
|
||||||
|
assertEquals(401, post("/api/auth/login", LOGIN).statusCode());
|
||||||
|
assertEquals(200, post("/api/auth/login", account("tata", NEW_PASSWORD)).statusCode());
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
post("/api/auth/password", wrong, "Cookie", session);
|
||||||
|
}
|
||||||
|
assertEquals(429, post("/api/auth/password", wrong, "Cookie", session).statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changesSurviveARestartBecauseTheFileIsRewritten() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
String session = signIn();
|
||||||
|
post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session);
|
||||||
|
|
||||||
|
launch(dir.resolve("users.json"), null, false);
|
||||||
|
|
||||||
|
assertEquals(200, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logoutClearsTheCookie() throws Exception {
|
||||||
|
launch(true, false, null);
|
||||||
|
|
||||||
|
var response = post("/api/auth/logout", "{}", "Cookie", signIn());
|
||||||
|
|
||||||
|
assertEquals(204, response.statusCode());
|
||||||
|
assertTrue(setCookie(response, "hp_session").startsWith("hp_session=; Max-Age=0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loginIsNotFoundWithoutLocalAccounts() throws Exception {
|
||||||
|
launch(false, true, null);
|
||||||
|
|
||||||
|
assertEquals(404, post("/api/auth/login", LOGIN).statusCode());
|
||||||
|
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":true,\"setup\":false}"), json(get("/api/auth/config")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledAuthenticationOpensTheApiAsPhone() throws Exception {
|
||||||
|
launch(null, null, true);
|
||||||
|
|
||||||
|
assertEquals(200, get("/api/health").statusCode());
|
||||||
|
assertEquals(JSON.readTree("{\"user\":\"phone\",\"method\":\"none\"}"), json(get("/api/auth/me")));
|
||||||
|
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":false,\"setup\":false}"), json(get("/api/auth/config")));
|
||||||
|
assertEquals(204, post("/api/auth/logout", "{}").statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OIDC ---
|
||||||
|
|
||||||
|
private record Started(Map<String, String> query, String transaction) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private Started start() throws Exception {
|
||||||
|
var response = get("/api/auth/oidc/start");
|
||||||
|
assertEquals(302, response.statusCode());
|
||||||
|
URI location = URI.create(response.headers().firstValue("Location").orElseThrow());
|
||||||
|
assertEquals(issuer.issuer() + "/protocol/openid-connect/auth", location.toString().split("\\?")[0]);
|
||||||
|
String set = setCookie(response, "hp_oidc");
|
||||||
|
assertTrue(set.contains("HttpOnly") && set.contains("Secure") && set.contains("SameSite=Lax")
|
||||||
|
&& set.contains("Max-Age=600"), set);
|
||||||
|
return new Started(query(location.getRawQuery()), cookie(response, "hp_oidc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> query(String raw) {
|
||||||
|
Map<String, String> query = new HashMap<>();
|
||||||
|
for (String pair : raw.split("&")) {
|
||||||
|
String[] kv = pair.split("=", 2);
|
||||||
|
query.put(kv[0], URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpResponse<String> callback(Started started, String queryString) throws Exception {
|
||||||
|
return get("/api/auth/oidc/callback?" + queryString, "Cookie", started.transaction());
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpResponse<String> callbackWithCode(Started started) throws Exception {
|
||||||
|
return callback(started, "code=abc&state=" + started.query().get("state"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oidcLoginSendsTheRightRequestAndSignsTheUserIn() throws Exception {
|
||||||
|
launch(false, true, "home-pump");
|
||||||
|
Started started = start();
|
||||||
|
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
|
||||||
|
|
||||||
|
var response = callbackWithCode(started);
|
||||||
|
|
||||||
|
assertEquals(302, response.statusCode());
|
||||||
|
assertEquals("/", response.headers().firstValue("Location").orElseThrow());
|
||||||
|
assertTrue(setCookie(response, "hp_oidc").startsWith("hp_oidc=; Max-Age=0"));
|
||||||
|
var me = get("/api/auth/me", "Cookie", cookie(response, "hp_session"));
|
||||||
|
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"oidc\"}"), json(me));
|
||||||
|
|
||||||
|
var authorize = started.query();
|
||||||
|
assertEquals("code", authorize.get("response_type"));
|
||||||
|
assertEquals(StubIssuer.CLIENT_ID, authorize.get("client_id"));
|
||||||
|
assertEquals(CALLBACK, authorize.get("redirect_uri"));
|
||||||
|
assertEquals("S256", authorize.get("code_challenge_method"));
|
||||||
|
assertTrue(authorize.get("scope").contains("openid"));
|
||||||
|
var token = issuer.tokenRequests.getFirst();
|
||||||
|
assertEquals("Basic " + Base64.getEncoder().encodeToString(
|
||||||
|
(StubIssuer.CLIENT_ID + ":" + StubIssuer.CLIENT_KEY).getBytes(StandardCharsets.UTF_8)),
|
||||||
|
token.authorization());
|
||||||
|
assertEquals("authorization_code", token.form().get("grant_type"));
|
||||||
|
assertEquals("abc", token.form().get("code"));
|
||||||
|
assertEquals(CALLBACK, token.form().get("redirect_uri"));
|
||||||
|
assertEquals(authorize.get("code_challenge"), Base64.getUrlEncoder().withoutPadding().encodeToString(
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(
|
||||||
|
token.form().get("code_verifier").getBytes(StandardCharsets.US_ASCII))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oidcTokensThatDoNotVerifyEndInOidcFailed() throws Exception {
|
||||||
|
launch(false, true, null);
|
||||||
|
long now = Instant.now().getEpochSecond();
|
||||||
|
List<Function<String, String>> bad = List.of(
|
||||||
|
nonce -> StubIssuer.sign("RS256", "k1", Map.of("iss", issuer.issuer().toString(),
|
||||||
|
"aud", StubIssuer.CLIENT_ID, "nonce", nonce, "iat", now, "exp", now + 300,
|
||||||
|
"preferred_username", "tata"), StubIssuer.newKeys().getPrivate()),
|
||||||
|
nonce -> issuer.idToken(nonce, c -> c.put("aud", "other")),
|
||||||
|
nonce -> issuer.idToken(nonce, c -> c.put("exp", now - 600)),
|
||||||
|
nonce -> issuer.idToken("another-nonce", c -> { }));
|
||||||
|
for (var token : bad) {
|
||||||
|
Started started = start();
|
||||||
|
issuer.idTokenToServe = token.apply(started.query().get("nonce"));
|
||||||
|
|
||||||
|
var response = callbackWithCode(started);
|
||||||
|
|
||||||
|
assertEquals("/#login?error=oidc_failed", response.headers().firstValue("Location").orElseThrow());
|
||||||
|
assertEquals(null, cookie(response, "hp_session"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrongStateOrMissingLoginIsRefusedWithoutAskingTheIssuer() throws Exception {
|
||||||
|
launch(false, true, null);
|
||||||
|
Started started = start();
|
||||||
|
|
||||||
|
var wrongState = callback(started, "code=abc&state=forged");
|
||||||
|
var noCookie = get("/api/auth/oidc/callback?code=abc&state=" + started.query().get("state"));
|
||||||
|
var noParameters = callback(started, "");
|
||||||
|
|
||||||
|
for (var response : List.of(wrongState, noCookie, noParameters)) {
|
||||||
|
assertEquals("/#login?error=oidc_failed", response.headers().firstValue("Location").orElseThrow());
|
||||||
|
}
|
||||||
|
assertTrue(issuer.tokenRequests.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingRoleIsDenied() throws Exception {
|
||||||
|
launch(false, true, "admin");
|
||||||
|
Started started = start();
|
||||||
|
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
|
||||||
|
|
||||||
|
var response = callbackWithCode(started);
|
||||||
|
|
||||||
|
assertEquals("/#login?error=oidc_denied", response.headers().firstValue("Location").orElseThrow());
|
||||||
|
assertEquals(null, cookie(response, "hp_session"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refusedByTheUserIsDenied() throws Exception {
|
||||||
|
launch(false, true, null);
|
||||||
|
Started started = start();
|
||||||
|
|
||||||
|
var response = callback(started, "error=access_denied&state=" + started.query().get("state"));
|
||||||
|
|
||||||
|
assertEquals("/#login?error=oidc_denied", response.headers().firstValue("Location").orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenEndpointRefusalIsOidcFailed() throws Exception {
|
||||||
|
launch(false, true, null);
|
||||||
|
Started started = start();
|
||||||
|
issuer.tokenStatus = 400;
|
||||||
|
|
||||||
|
assertEquals("/#login?error=oidc_failed",
|
||||||
|
callbackWithCode(started).headers().firstValue("Location").orElseThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keycloakDownBeforeTheStartRedirectsBackWhileLocalLoginWorks() throws Exception {
|
||||||
|
issuer = new StubIssuer();
|
||||||
|
URI gone = issuer.issuer();
|
||||||
|
issuer.stop();
|
||||||
|
launch(users(), oidcSettings(gone, null), false);
|
||||||
|
|
||||||
|
var start = get("/api/auth/oidc/start");
|
||||||
|
|
||||||
|
assertEquals(302, start.statusCode());
|
||||||
|
assertEquals("/#login?error=oidc_unavailable", start.headers().firstValue("Location").orElseThrow());
|
||||||
|
assertEquals(null, cookie(start, "hp_oidc"));
|
||||||
|
assertEquals(200, get("/api/auth/config").statusCode());
|
||||||
|
assertTrue(signIn() != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keycloakDownDuringTheCallbackIsUnavailable() throws Exception {
|
||||||
|
launch(true, true, null);
|
||||||
|
Started started = start();
|
||||||
|
issuer.stop();
|
||||||
|
|
||||||
|
var response = callbackWithCode(started);
|
||||||
|
|
||||||
|
assertEquals("/#login?error=oidc_unavailable", response.headers().firstValue("Location").orElseThrow());
|
||||||
|
assertEquals(200, post("/api/auth/login", LOGIN).statusCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package home.backend;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** The environment rules that keep the backend from starting open by accident. */
|
||||||
|
class AuthSettingsTest {
|
||||||
|
|
||||||
|
private static final String SIGNING_KEY = "0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
|
private static Map<String, String> env(String... extra) {
|
||||||
|
Map<String, String> env = new HashMap<>(Map.of("GATEWAY_URL", "http://10.0.0.2:8080",
|
||||||
|
"INFLUX_URL", "https://influx.example"));
|
||||||
|
for (int i = 0; i < extra.length; i += 2) {
|
||||||
|
env.put(extra[i], extra[i + 1]);
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String refusal(String... extra) {
|
||||||
|
return assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(env(extra))).getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutAnySignInTheBackendRefusesToStart() {
|
||||||
|
assertTrue(refusal().contains("AUTH_DISABLED=true"));
|
||||||
|
assertTrue(refusal("AUTH_DISABLED", "false").contains("no sign-in configured"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authDisabledIsAnExplicitChoice() {
|
||||||
|
var auth = Settings.fromEnvironment(env("AUTH_DISABLED", "true")).auth();
|
||||||
|
|
||||||
|
assertTrue(auth.disabled());
|
||||||
|
assertNull(auth.oidc());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledContradictsConfiguredSignIn() {
|
||||||
|
assertTrue(refusal("AUTH_DISABLED", "true", "USERS_FILE", "/data/users.json").contains("contradicts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void configuredSignInNeedsALongSessionSecret() {
|
||||||
|
assertTrue(refusal("USERS_FILE", "/data/users.json").contains("SESSION_SECRET"));
|
||||||
|
assertTrue(refusal("USERS_FILE", "/data/users.json", "SESSION_SECRET", "short").contains("32 bytes"));
|
||||||
|
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "OIDC_CLIENT_ID", "x",
|
||||||
|
"OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb").contains("SESSION_SECRET"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oidcWithoutARequiredRoleWarns() {
|
||||||
|
var open = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
|
||||||
|
"OIDC_CLIENT_ID", "x", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb",
|
||||||
|
"SESSION_SECRET", SIGNING_KEY)).auth();
|
||||||
|
var restricted = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
|
||||||
|
"OIDC_CLIENT_ID", "x", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb",
|
||||||
|
"OIDC_REQUIRED_ROLE", "family", "SESSION_SECRET", SIGNING_KEY)).auth();
|
||||||
|
|
||||||
|
assertTrue(open.warnings().getFirst().contains("every user of the Keycloak realm"));
|
||||||
|
assertTrue(restricted.warnings().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oidcNeedsItsClientSettings() {
|
||||||
|
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "SESSION_SECRET", SIGNING_KEY)
|
||||||
|
.contains("OIDC_CLIENT_ID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void localAndOidcAreConfiguredIndependently() {
|
||||||
|
var local = Settings.fromEnvironment(env("USERS_FILE", "/data/users.json", "SESSION_SECRET", SIGNING_KEY)).auth();
|
||||||
|
var oidc = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
|
||||||
|
"OIDC_CLIENT_ID", "home-pump", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI",
|
||||||
|
"https://pompa.example/api/auth/oidc/callback", "OIDC_REQUIRED_ROLE", "family",
|
||||||
|
"SESSION_SECRET", SIGNING_KEY, "SESSION_DAYS", "7", "TRUSTED_PROXY_HOPS", "2")).auth();
|
||||||
|
|
||||||
|
assertEquals("/data/users.json", local.usersFile().toString());
|
||||||
|
assertNull(local.oidc());
|
||||||
|
assertEquals(30, local.sessionDays());
|
||||||
|
assertEquals(1, local.proxyHops());
|
||||||
|
assertNull(oidc.usersFile());
|
||||||
|
assertEquals("family", oidc.oidc().requiredRole());
|
||||||
|
assertEquals(7, oidc.sessionDays());
|
||||||
|
assertEquals(2, oidc.proxyHops());
|
||||||
|
assertFalse(oidc.disabled());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.sun.net.httpserver.HttpExchange;
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
import com.sun.net.httpserver.HttpServer;
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import home.backend.adapters.auth.AuthSettings;
|
||||||
import home.backend.adapters.http.ApiServer;
|
import home.backend.adapters.http.ApiServer;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -43,6 +44,8 @@ class BackendTest {
|
|||||||
/** Just after the readings in the snapshot fixture, so they count as fresh. */
|
/** Just after the readings in the snapshot fixture, so they count as fresh. */
|
||||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC);
|
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC);
|
||||||
|
|
||||||
|
private static final AuthSettings OPEN = new AuthSettings(null, null, null, 30, true, 1);
|
||||||
|
|
||||||
private final HttpClient client = HttpClient.newHttpClient();
|
private final HttpClient client = HttpClient.newHttpClient();
|
||||||
private HttpServer gateway;
|
private HttpServer gateway;
|
||||||
private HttpServer influx;
|
private HttpServer influx;
|
||||||
@@ -56,7 +59,7 @@ class BackendTest {
|
|||||||
gateway.createContext("/commands", this::commands);
|
gateway.createContext("/commands", this::commands);
|
||||||
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
|
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
|
||||||
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
|
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
|
||||||
"s3cret-token", null);
|
"s3cret-token", null, OPEN);
|
||||||
backend = Backend.assemble(settings, CLOCK);
|
backend = Backend.assemble(settings, CLOCK);
|
||||||
backend.start();
|
backend.start();
|
||||||
}
|
}
|
||||||
@@ -83,7 +86,7 @@ class BackendTest {
|
|||||||
|
|
||||||
private HttpResponse<String> post(String path, String body) throws Exception {
|
private HttpResponse<String> post(String path, String body) throws Exception {
|
||||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
|
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
|
||||||
.header("Remote-User", "anna").header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("X-Requested-With", "home-pump")
|
.header("X-Requested-With", "home-pump")
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
||||||
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
@@ -151,6 +154,11 @@ class BackendTest {
|
|||||||
assertServesExactly("/api/heat-pump", "heat-pump");
|
assertServesExactly("/api/heat-pump", "heat-pump");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void servesPiSensorFromTheGatewayReadings() throws Exception {
|
||||||
|
assertServesExactly("/api/pi-sensor", "pi-sensor");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void servesEnergyFromTheGatewayReadings() throws Exception {
|
void servesEnergyFromTheGatewayReadings() throws Exception {
|
||||||
assertServesExactly("/api/energy", "energy");
|
assertServesExactly("/api/energy", "energy");
|
||||||
@@ -194,7 +202,7 @@ class BackendTest {
|
|||||||
JSON.readTree(response.body()));
|
JSON.readTree(response.body()));
|
||||||
assertEquals(1, commandsSeen.size());
|
assertEquals(1, commandsSeen.size());
|
||||||
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
|
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
|
||||||
assertEquals("anna", commandsSeen.getFirst().requestedBy());
|
assertEquals("phone", commandsSeen.getFirst().requestedBy());
|
||||||
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
|
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
|
||||||
JSON.readTree(commandsSeen.getFirst().body()));
|
JSON.readTree(commandsSeen.getFirst().body()));
|
||||||
}
|
}
|
||||||
@@ -204,7 +212,7 @@ class BackendTest {
|
|||||||
Path file = Files.writeString(dir.resolve("presets.json"),
|
Path file = Files.writeString(dir.resolve("presets.json"),
|
||||||
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
|
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
|
||||||
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||||
Duration.ofSeconds(2), "s3cret-token", file), CLOCK);
|
Duration.ofSeconds(2), "s3cret-token", file, OPEN), CLOCK);
|
||||||
custom.start();
|
custom.start();
|
||||||
try {
|
try {
|
||||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
|
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
|
||||||
@@ -224,7 +232,7 @@ class BackendTest {
|
|||||||
@Test
|
@Test
|
||||||
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
|
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
|
||||||
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||||
Duration.ofSeconds(2), null, null), CLOCK);
|
Duration.ofSeconds(2), null, null, OPEN), CLOCK);
|
||||||
noToken.start();
|
noToken.start();
|
||||||
try {
|
try {
|
||||||
var base = "http://127.0.0.1:" + noToken.port();
|
var base = "http://127.0.0.1:" + noToken.port();
|
||||||
@@ -251,7 +259,7 @@ class BackendTest {
|
|||||||
@Test
|
@Test
|
||||||
void urlsMayEndWithASingleSlash() {
|
void urlsMayEndWithASingleSlash() {
|
||||||
var settings = Settings.fromEnvironment(
|
var settings = Settings.fromEnvironment(
|
||||||
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/"));
|
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/", "AUTH_DISABLED", "true"));
|
||||||
|
|
||||||
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
|
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
|
||||||
}
|
}
|
||||||
@@ -259,7 +267,7 @@ class BackendTest {
|
|||||||
@Test
|
@Test
|
||||||
void settingsHaveDefaults() {
|
void settingsHaveDefaults() {
|
||||||
var settings = Settings.fromEnvironment(
|
var settings = Settings.fromEnvironment(
|
||||||
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example"));
|
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example", "AUTH_DISABLED", "true"));
|
||||||
|
|
||||||
assertEquals(8080, settings.port());
|
assertEquals(8080, settings.port());
|
||||||
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
|
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
|
||||||
@@ -268,7 +276,8 @@ class BackendTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void tokenAndPresetsFileAreOptional() {
|
void tokenAndPresetsFileAreOptional() {
|
||||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
|
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example",
|
||||||
|
"AUTH_DISABLED", "true");
|
||||||
var none = Settings.fromEnvironment(urls);
|
var none = Settings.fromEnvironment(urls);
|
||||||
var env = new HashMap<>(urls);
|
var env = new HashMap<>(urls);
|
||||||
env.put("GATEWAY_TOKEN", " s3cret-token ");
|
env.put("GATEWAY_TOKEN", " s3cret-token ");
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
|
||||||
|
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
class IdTokenVerifierTest {
|
||||||
|
|
||||||
|
private static StubIssuer issuer;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void start() throws IOException {
|
||||||
|
issuer = new StubIssuer();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void stop() {
|
||||||
|
issuer.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IdTokenVerifier verifier(String role) {
|
||||||
|
return new IdTokenVerifier(new OidcSettings(issuer.issuer(), StubIssuer.CLIENT_ID, "x",
|
||||||
|
URI.create("https://pompa.example/api/auth/oidc/callback"), role), Clock.systemUTC());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String verify(String token, String role) throws OidcException {
|
||||||
|
return verifier(role).verify(token, "n1", kid -> kid.equals("k1") ? issuer.publicKey() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OidcException failure(String token, String role) {
|
||||||
|
return assertThrows(OidcException.class, () -> verify(token, role));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String token(Consumer<Map<String, Object>> change) {
|
||||||
|
return issuer.idToken("n1", change);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validTokenGivesTheUserName() throws Exception {
|
||||||
|
assertEquals("tata", verify(token(c -> { }), "home-pump"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void userNameFallsBackToEmailThenSubject() throws Exception {
|
||||||
|
assertEquals("tata@example.pl", verify(token(c -> {
|
||||||
|
c.remove("preferred_username");
|
||||||
|
c.put("email", "tata@example.pl");
|
||||||
|
}), null));
|
||||||
|
assertEquals("7f1c", verify(token(c -> c.remove("preferred_username")), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signatureOfAnotherKeyIsRefused() {
|
||||||
|
var forged = StubIssuer.sign("RS256", "k1", Map.of("iss", issuer.issuer().toString(), "aud", "home-pump"),
|
||||||
|
StubIssuer.newKeys().getPrivate());
|
||||||
|
|
||||||
|
assertEquals(FAILED, failure(forged, null).reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tamperedPayloadIsRefused() {
|
||||||
|
String[] parts = token(c -> { }).split("\\.");
|
||||||
|
String other = token(c -> c.put("preferred_username", "admin")).split("\\.")[1];
|
||||||
|
|
||||||
|
assertEquals(FAILED, failure(parts[0] + "." + other + "." + parts[2], null).reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void otherAlgorithmsAreRefused() {
|
||||||
|
String none = "eyJhbGciOiJub25lIn0." + token(c -> { }).split("\\.")[1] + ".";
|
||||||
|
|
||||||
|
assertEquals(FAILED, failure(none, null).reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenForAnotherClientIsRefused() {
|
||||||
|
assertEquals(FAILED, failure(token(c -> c.put("aud", "other-app")), null).reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void severalAudiencesNeedOurClientAsAuthorizedParty() throws Exception {
|
||||||
|
assertEquals(FAILED, failure(token(c -> c.put("aud", List.of("home-pump", "account"))), null).reason());
|
||||||
|
assertEquals("tata", verify(token(c -> {
|
||||||
|
c.put("aud", List.of("home-pump", "account"));
|
||||||
|
c.put("azp", "home-pump");
|
||||||
|
}), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrongIssuerIsRefused() {
|
||||||
|
assertEquals(FAILED, failure(token(c -> c.put("iss", "https://evil.example/realms/home")), null).reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredTokenIsRefusedButASmallSkewIsAllowed() throws Exception {
|
||||||
|
long now = java.time.Instant.now().getEpochSecond();
|
||||||
|
|
||||||
|
assertEquals(FAILED, failure(token(c -> c.put("exp", now - 120)), null).reason());
|
||||||
|
assertEquals("tata", verify(token(c -> c.put("exp", now - 10)), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenIssuedInTheFutureIsRefused() {
|
||||||
|
long now = java.time.Instant.now().getEpochSecond();
|
||||||
|
|
||||||
|
assertEquals(FAILED, failure(token(c -> c.put("iat", now + 600)), null).reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrongNonceIsRefused() {
|
||||||
|
assertEquals(FAILED, failure(issuer.idToken("another", c -> { }), null).reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiredRoleMayBeARealmRoleOrAClientRole() throws Exception {
|
||||||
|
assertEquals("tata", verify(token(c -> { }), "home-pump"));
|
||||||
|
assertEquals("tata", verify(token(c -> {
|
||||||
|
c.remove("realm_access");
|
||||||
|
c.put("resource_access", Map.of("home-pump", Map.of("roles", List.of("family"))));
|
||||||
|
}), "family"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingRoleIsDenied() {
|
||||||
|
assertEquals(DENIED, failure(token(c -> { }), "admin").reason());
|
||||||
|
assertEquals(DENIED, failure(token(c -> c.put("resource_access",
|
||||||
|
Map.of("account", Map.of("roles", List.of("admin"))))), "admin").reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownKeyIsRefused() {
|
||||||
|
var verifier = verifier(null);
|
||||||
|
|
||||||
|
assertEquals(FAILED, assertThrows(OidcException.class,
|
||||||
|
() -> verifier.verify(token(c -> { }), "n1", kid -> {
|
||||||
|
throw new OidcException(FAILED, "no signing key " + kid);
|
||||||
|
})).reason());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Hashing, the accounts file and the hash-password command. */
|
||||||
|
class PasswordTest {
|
||||||
|
|
||||||
|
private static String hash;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void hashOnce() {
|
||||||
|
hash = PasswordHash.create("correct horse".toCharArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hashIsSelfDescribingSaltedAndChecked() {
|
||||||
|
String[] parts = hash.split("\\$");
|
||||||
|
|
||||||
|
assertEquals("pbkdf2-sha256", parts[0]);
|
||||||
|
assertTrue(Integer.parseInt(parts[1]) >= 310_000);
|
||||||
|
assertTrue(PasswordHash.matches(hash, "correct horse".toCharArray()));
|
||||||
|
assertFalse(PasswordHash.matches(hash, "wrong horse".toCharArray()));
|
||||||
|
assertNotEquals(hash, PasswordHash.create("correct horse".toCharArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void weakOrMalformedHashesAreRefused() {
|
||||||
|
for (String bad : new String[] {"", "plain", "pbkdf2-sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA",
|
||||||
|
"pbkdf2-sha256$x$y$z", "md5$600000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA"}) {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> PasswordHash.requireValid(bad), bad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usersFileHoldsAccounts(@TempDir Path dir) throws IOException {
|
||||||
|
UserStore users = UserStore.load(file(dir, "[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]"));
|
||||||
|
|
||||||
|
assertTrue(users.authenticate("tata", "correct horse".toCharArray()));
|
||||||
|
assertFalse(users.authenticate("tata", "nope".toCharArray()));
|
||||||
|
assertFalse(users.authenticate("ghost", "correct horse".toCharArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void badUsersFilesStopTheStart(@TempDir Path dir) throws IOException {
|
||||||
|
String account = "{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}";
|
||||||
|
for (String bad : new String[] {"not json", "[" + account + "," + account + "]",
|
||||||
|
"[{\"username\":\"a b\",\"passwordHash\":\"" + hash + "\"}]",
|
||||||
|
"[{\"username\":\"tata\",\"passwordHash\":\"plain\"}]"}) {
|
||||||
|
Path file = file(dir, bad);
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> UserStore.load(file), bad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingOrEmptyFileIsAnEmptyStoreForSetup(@TempDir Path dir) throws IOException {
|
||||||
|
assertTrue(UserStore.load(dir.resolve("missing.json")).isEmpty());
|
||||||
|
assertTrue(UserStore.load(file(dir, "[]")).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changesAreWrittenAtomicallyPrivatelyAndReadBack(@TempDir Path dir) throws IOException {
|
||||||
|
Path file = dir.resolve("users.json");
|
||||||
|
UserStore users = UserStore.load(file);
|
||||||
|
|
||||||
|
assertTrue(users.addFirst("tata", hash));
|
||||||
|
assertFalse(users.addFirst("mama", hash));
|
||||||
|
assertTrue(users.add("mama", hash));
|
||||||
|
assertFalse(users.add("mama", hash));
|
||||||
|
assertTrue(users.changePassword("mama", PasswordHash.create("another password".toCharArray())));
|
||||||
|
assertFalse(users.changePassword("ghost", hash));
|
||||||
|
|
||||||
|
assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file)));
|
||||||
|
try (var listing = Files.list(dir)) {
|
||||||
|
assertEquals(List.of("users.json"), listing.map(p -> p.getFileName().toString()).toList());
|
||||||
|
}
|
||||||
|
UserStore again = UserStore.load(file);
|
||||||
|
assertEquals(List.of("tata", "mama"), again.usernames());
|
||||||
|
assertTrue(again.authenticate("tata", "correct horse".toCharArray()));
|
||||||
|
assertTrue(again.authenticate("mama", "another password".toCharArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lastAccountCannotBeRemoved(@TempDir Path dir) {
|
||||||
|
UserStore users = UserStore.load(dir.resolve("users.json"));
|
||||||
|
users.addFirst("tata", hash);
|
||||||
|
users.add("mama", hash);
|
||||||
|
|
||||||
|
assertEquals(UserStore.Removal.REMOVED, users.remove("mama"));
|
||||||
|
assertEquals(UserStore.Removal.LAST, users.remove("tata"));
|
||||||
|
assertEquals(UserStore.Removal.UNKNOWN, users.remove("mama"));
|
||||||
|
assertEquals(List.of("tata"), UserStore.load(dir.resolve("users.json")).usernames());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedWriteLeavesTheStoreAsItWas(@TempDir Path dir) {
|
||||||
|
UserStore users = UserStore.load(dir.resolve("gone/users.json"));
|
||||||
|
|
||||||
|
assertThrows(UncheckedIOException.class, () -> users.addFirst("tata", hash));
|
||||||
|
assertTrue(users.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unknown user is run through the same hashing, so the answer does not tell which names exist. */
|
||||||
|
@Test
|
||||||
|
void unknownUserCostsAboutAsMuchAsAWrongPassword(@TempDir Path dir) throws IOException {
|
||||||
|
UserStore users = UserStore.load(file(dir, "[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]"));
|
||||||
|
users.authenticate("tata", "warm up".toCharArray());
|
||||||
|
|
||||||
|
long wrong = nanos(() -> users.authenticate("tata", "nope".toCharArray()));
|
||||||
|
long unknown = nanos(() -> users.authenticate("ghost", "nope".toCharArray()));
|
||||||
|
|
||||||
|
assertTrue(unknown > wrong / 2, "unknown " + unknown + " ns vs wrong " + wrong + " ns");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void commandPrintsAHashFromStdin() throws IOException {
|
||||||
|
var out = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
int code = PasswordHashCli.run(null, stream("s3cr3t-pass\n"), new PrintStream(out), System.err);
|
||||||
|
|
||||||
|
assertEquals(0, code);
|
||||||
|
assertTrue(PasswordHash.matches(out.toString(StandardCharsets.UTF_8).trim(), "s3cr3t-pass".toCharArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void commandRefusesAnEmptyPassword() throws IOException {
|
||||||
|
var err = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
assertEquals(1, PasswordHashCli.run(null, stream("\n"), System.out, new PrintStream(err)));
|
||||||
|
assertTrue(err.toString(StandardCharsets.UTF_8).contains("No password"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ByteArrayInputStream stream(String text) {
|
||||||
|
return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path file(Path dir, String content) throws IOException {
|
||||||
|
return Files.writeString(dir.resolve("users-" + content.hashCode() + ".json"), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long nanos(Runnable action) {
|
||||||
|
long start = System.nanoTime();
|
||||||
|
action.run();
|
||||||
|
return System.nanoTime() - start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** The session cookie, the throttle and the other signed cookie. */
|
||||||
|
class SessionsTest {
|
||||||
|
|
||||||
|
private static final byte[] SIGNING_KEY = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
|
||||||
|
private static final Instant T0 = Instant.parse("2026-10-24T10:00:00Z");
|
||||||
|
|
||||||
|
private Instant now = T0;
|
||||||
|
private final Clock clock = new Clock() {
|
||||||
|
@Override
|
||||||
|
public ZoneOffset getZone() {
|
||||||
|
return ZoneOffset.UTC;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Clock withZone(java.time.ZoneId zone) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Instant instant() {
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
private final SignedTokens tokens = new SignedTokens(SIGNING_KEY, clock);
|
||||||
|
private final Sessions sessions = new Sessions(tokens, clock, Duration.ofDays(30));
|
||||||
|
|
||||||
|
private static String cookieHeader(String setCookie) {
|
||||||
|
return setCookie.split(";")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cookieCarriesTheSessionAndTheRightFlags() {
|
||||||
|
String setCookie = sessions.setCookie("tata", "local");
|
||||||
|
|
||||||
|
assertEquals(Optional.of(new Session("tata", "local", T0)), sessions.read(cookieHeader(setCookie)));
|
||||||
|
for (String flag : new String[] {"HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=2592000"}) {
|
||||||
|
assertTrue(setCookie.contains(flag), flag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sessionExpiresAfterItsLifetime() {
|
||||||
|
String cookie = cookieHeader(sessions.setCookie("tata", "oidc"));
|
||||||
|
|
||||||
|
now = T0.plus(Duration.ofDays(30)).minusSeconds(1);
|
||||||
|
assertTrue(sessions.read(cookie).isPresent());
|
||||||
|
now = T0.plus(Duration.ofDays(30));
|
||||||
|
assertTrue(sessions.read(cookie).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tamperedOrForeignCookiesAreRefused() {
|
||||||
|
String cookie = cookieHeader(sessions.setCookie("tata", "local"));
|
||||||
|
String other = cookieHeader(sessions.setCookie("mama", "local"));
|
||||||
|
String forgedPayload = other.substring(0, other.indexOf('.')) + cookie.substring(cookie.indexOf('.'));
|
||||||
|
var otherSecret = new Sessions(new SignedTokens("another-secret-another-secret-12".getBytes(StandardCharsets.UTF_8),
|
||||||
|
clock), clock, Duration.ofDays(30));
|
||||||
|
|
||||||
|
assertTrue(sessions.read(forgedPayload).isEmpty());
|
||||||
|
assertTrue(sessions.read(cookie + "x").isEmpty());
|
||||||
|
assertTrue(sessions.read("hp_session=garbage").isEmpty());
|
||||||
|
assertTrue(sessions.read("hp_session=a.b.c").isEmpty());
|
||||||
|
assertTrue(sessions.read(null).isEmpty());
|
||||||
|
assertTrue(otherSecret.read(cookie).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aTokenForAnotherPurposeIsNotASession() {
|
||||||
|
String token = tokens.issue("oidc-login", Duration.ofMinutes(10),
|
||||||
|
Map.of("user", "tata", "method", "local", "iat", "0"));
|
||||||
|
|
||||||
|
assertTrue(sessions.read("hp_session=" + token).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearingExpiresTheCookie() {
|
||||||
|
assertTrue(sessions.clearCookie().startsWith("hp_session=; Max-Age=0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void throttleBlocksTheSixthAttemptUntilTheOldestLeavesTheWindow() {
|
||||||
|
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
now = T0.plusSeconds(60);
|
||||||
|
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
|
||||||
|
|
||||||
|
assertEquals(15 * 60 - 60 + 1, throttle.tryAcquire("user:tata"));
|
||||||
|
assertEquals(0, throttle.tryAcquire("user:mama", "ip:5.6.7.8"));
|
||||||
|
now = T0.plus(Duration.ofMinutes(15));
|
||||||
|
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void releasedAttemptsAndSuccessDoNotCount() {
|
||||||
|
var throttle = new LoginThrottle(clock, 2, Duration.ofMinutes(15));
|
||||||
|
throttle.tryAcquire("user:tata");
|
||||||
|
throttle.release("user:tata");
|
||||||
|
throttle.tryAcquire("user:tata");
|
||||||
|
assertEquals(0, throttle.tryAcquire("user:tata"));
|
||||||
|
assertTrue(throttle.tryAcquire("user:tata") > 0);
|
||||||
|
|
||||||
|
throttle.cleared("user:tata");
|
||||||
|
|
||||||
|
assertEquals(0, throttle.tryAcquire("user:tata"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parallelAttemptsCannotOutrunTheLimit() throws Exception {
|
||||||
|
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
|
||||||
|
var pool = java.util.concurrent.Executors.newFixedThreadPool(32);
|
||||||
|
var allowed = new java.util.concurrent.atomic.AtomicInteger();
|
||||||
|
var tasks = new java.util.ArrayList<java.util.concurrent.Future<?>>();
|
||||||
|
for (int i = 0; i < 200; i++) {
|
||||||
|
tasks.add(pool.submit(() -> {
|
||||||
|
if (throttle.tryAcquire("user:tata", "ip:1.2.3.4") == 0) {
|
||||||
|
allowed.incrementAndGet();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for (var task : tasks) {
|
||||||
|
task.get();
|
||||||
|
}
|
||||||
|
pool.shutdown();
|
||||||
|
|
||||||
|
assertEquals(5, allowed.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void odditiesInTheProvidersErrorAreCleanedForTheLog() {
|
||||||
|
assertEquals("server_error", OidcFlow.printable("server_error"));
|
||||||
|
assertEquals("a?b?c", OidcFlow.printable("a\nb\rc"));
|
||||||
|
assertEquals(40 + 3, OidcFlow.printable("x".repeat(100)).length());
|
||||||
|
assertEquals("nothing", OidcFlow.printable(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package home.backend.adapters.auth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.Signature;
|
||||||
|
import java.security.interfaces.RSAPublicKey;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/** A Keycloak stand-in on localhost: discovery, signing keys and a token endpoint that hands out what a test says. */
|
||||||
|
public final class StubIssuer {
|
||||||
|
|
||||||
|
public static final String CLIENT_ID = "home-pump";
|
||||||
|
public static final String CLIENT_KEY = "stub-client-secret";
|
||||||
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
|
|
||||||
|
/** What the token endpoint received. */
|
||||||
|
public record TokenRequest(String authorization, Map<String, String> form) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public final List<TokenRequest> tokenRequests = new ArrayList<>();
|
||||||
|
/** What the token endpoint answers with; set by the test. */
|
||||||
|
public volatile String idTokenToServe = "";
|
||||||
|
public volatile int tokenStatus = 200;
|
||||||
|
|
||||||
|
private final KeyPair keys = newKeys();
|
||||||
|
private final HttpServer server;
|
||||||
|
|
||||||
|
public StubIssuer() throws IOException {
|
||||||
|
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||||
|
server.createContext("/realms/home/.well-known/openid-configuration", exchange -> reply(exchange, 200,
|
||||||
|
Map.of("issuer", issuer().toString(),
|
||||||
|
"authorization_endpoint", issuer() + "/protocol/openid-connect/auth",
|
||||||
|
"token_endpoint", issuer() + "/protocol/openid-connect/token",
|
||||||
|
"jwks_uri", issuer() + "/protocol/openid-connect/certs")));
|
||||||
|
server.createContext("/realms/home/protocol/openid-connect/certs", exchange -> reply(exchange, 200,
|
||||||
|
Map.of("keys", List.of(Map.of("kty", "RSA", "kid", "broken", "n", "***", "e", "AQAB"), jwk()))));
|
||||||
|
server.createContext("/realms/home/protocol/openid-connect/token", this::token);
|
||||||
|
server.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public URI issuer() {
|
||||||
|
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/realms/home");
|
||||||
|
}
|
||||||
|
|
||||||
|
public java.security.PublicKey publicKey() {
|
||||||
|
return keys.getPublic();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A valid ID token for the nonce, with {@code change} applied to the claims. */
|
||||||
|
public String idToken(String nonce, Consumer<Map<String, Object>> change) {
|
||||||
|
long now = Instant.now().getEpochSecond();
|
||||||
|
Map<String, Object> claims = new LinkedHashMap<>();
|
||||||
|
claims.put("iss", issuer().toString());
|
||||||
|
claims.put("aud", CLIENT_ID);
|
||||||
|
claims.put("sub", "7f1c");
|
||||||
|
claims.put("preferred_username", "tata");
|
||||||
|
claims.put("iat", now);
|
||||||
|
claims.put("exp", now + 300);
|
||||||
|
claims.put("nonce", nonce);
|
||||||
|
claims.put("realm_access", Map.of("roles", List.of("home-pump")));
|
||||||
|
change.accept(claims);
|
||||||
|
return sign("RS256", "k1", claims, keys.getPrivate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs with any key, so a test can forge a token. */
|
||||||
|
public static String sign(String alg, String kid, Map<String, Object> claims, PrivateKey key) {
|
||||||
|
try {
|
||||||
|
String header = Base64Url.encode(JSON.writeValueAsBytes(Map.of("alg", alg, "kid", kid, "typ", "JWT")));
|
||||||
|
String signed = header + "." + Base64Url.encode(JSON.writeValueAsBytes(claims));
|
||||||
|
Signature rsa = Signature.getInstance("SHA256withRSA");
|
||||||
|
rsa.initSign(key);
|
||||||
|
rsa.update(signed.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
return signed + "." + Base64Url.encode(rsa.sign());
|
||||||
|
} catch (IOException | GeneralSecurityException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static KeyPair newKeys() {
|
||||||
|
try {
|
||||||
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
generator.initialize(2048);
|
||||||
|
return generator.generateKeyPair();
|
||||||
|
} catch (GeneralSecurityException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> jwk() {
|
||||||
|
var key = (RSAPublicKey) keys.getPublic();
|
||||||
|
return Map.of("kty", "RSA", "use", "sig", "alg", "RS256", "kid", "k1",
|
||||||
|
"n", Base64Url.encode(unsigned(key.getModulus())), "e", Base64Url.encode(unsigned(key.getPublicExponent())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] unsigned(BigInteger number) {
|
||||||
|
byte[] bytes = number.toByteArray();
|
||||||
|
return bytes[0] == 0 ? java.util.Arrays.copyOfRange(bytes, 1, bytes.length) : bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void token(HttpExchange exchange) throws IOException {
|
||||||
|
Map<String, String> form = new HashMap<>();
|
||||||
|
for (String pair : new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8).split("&")) {
|
||||||
|
String[] kv = pair.split("=", 2);
|
||||||
|
form.put(URLDecoder.decode(kv[0], StandardCharsets.UTF_8), URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
tokenRequests.add(new TokenRequest(exchange.getRequestHeaders().getFirst("Authorization"), form));
|
||||||
|
reply(exchange, tokenStatus, Map.of("id_token", idTokenToServe, "token_type", "Bearer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void reply(HttpExchange exchange, int status, Object body) throws IOException {
|
||||||
|
byte[] bytes = JSON.writeValueAsBytes(body);
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||||
|
exchange.sendResponseHeaders(status, bytes.length);
|
||||||
|
exchange.getResponseBody().write(bytes);
|
||||||
|
exchange.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,8 +28,8 @@ class PresetFileTest {
|
|||||||
var presets = PresetFile.load(null);
|
var presets = PresetFile.load(null);
|
||||||
|
|
||||||
assertEquals(List.of(
|
assertEquals(List.of(
|
||||||
new Preset("cwu-now", "Ciepła woda teraz", List.of(new Change("cwu_economy", 45, 2.0))),
|
new Preset("cwu-now", "Ciepła woda teraz", null, List.of(new Change("cwu_economy", 45, 2.0))),
|
||||||
new Preset("away", "Wyjazd", List.of(new Change("cwu_comfort", 10, null),
|
new Preset("away", "Wyjazd", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||||
new Change("co_comfort", 15, null)))), presets);
|
new Change("co_comfort", 15, null)))), presets);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ class PresetFileTest {
|
|||||||
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
|
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
|
||||||
"""));
|
"""));
|
||||||
|
|
||||||
assertEquals(List.of(new Preset("bath", "Kąpiel", List.of(new Change("cwu_economy", 48, 1.5)))), presets);
|
assertEquals(List.of(new Preset("bath", "Kąpiel", null, List.of(new Change("cwu_economy", 48, 1.5)))), presets);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -51,6 +51,8 @@ class PresetFileTest {
|
|||||||
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
|
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
|
||||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
|
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
|
||||||
|
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"tomorrow\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||||
|
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"chosen\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 2}]}]",
|
||||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
|
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
|
||||||
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
|
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
|
||||||
Path file = file(json);
|
Path file = file(json);
|
||||||
|
|||||||
@@ -86,6 +86,20 @@ class GatewayHttpClientTest {
|
|||||||
assertEquals(645.0, device(devices, "deye").measurements().get(0).fields().get("grid_power_w"));
|
assertEquals(645.0, device(devices, "deye").measurements().get(0).fields().get("grid_power_w"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsTheGatewayVersion() throws Exception {
|
||||||
|
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
|
||||||
|
|
||||||
|
assertEquals("v1.2.1", client.snapshot().version());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anOldGatewayWithoutVersionHasNullVersion() throws Exception {
|
||||||
|
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||||
|
|
||||||
|
assertNull(client.snapshot().version());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
|
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
|
||||||
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ package home.backend.adapters.gateway;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
import com.sun.net.httpserver.HttpServer;
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import home.backend.domain.BatteryChange;
|
||||||
|
import home.backend.domain.BatteryResult;
|
||||||
|
import home.backend.domain.BatteryView;
|
||||||
import home.backend.domain.Command;
|
import home.backend.domain.Command;
|
||||||
import home.backend.domain.CommandResult.Status;
|
import home.backend.domain.CommandResult.Status;
|
||||||
|
import home.backend.domain.RefreshResult;
|
||||||
|
import home.backend.domain.RestoreResult.Outcome;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -18,6 +24,7 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -32,7 +39,7 @@ class GatewayHttpCommandsTest {
|
|||||||
private static final ObjectMapper JSON = new ObjectMapper();
|
private static final ObjectMapper JSON = new ObjectMapper();
|
||||||
private static final Path CONTRACT = Path.of("../gateway/src/test/resources/commands");
|
private static final Path CONTRACT = Path.of("../gateway/src/test/resources/commands");
|
||||||
|
|
||||||
private record Seen(String method, String authorization, String requestedBy, String body) {
|
private record Seen(String method, String path, String authorization, String requestedBy, String body) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private final List<Seen> seen = new ArrayList<>();
|
private final List<Seen> seen = new ArrayList<>();
|
||||||
@@ -59,16 +66,20 @@ class GatewayHttpCommandsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void answerCommands() {
|
private void answerCommands() {
|
||||||
server.createContext("/commands", exchange -> {
|
for (String path : List.of("/commands", "/battery", "/refresh")) {
|
||||||
seen.add(new Seen(exchange.getRequestMethod(),
|
server.createContext(path, this::answer);
|
||||||
exchange.getRequestHeaders().getFirst("Authorization"),
|
}
|
||||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
}
|
||||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
|
||||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
private void answer(HttpExchange exchange) throws IOException {
|
||||||
exchange.sendResponseHeaders(status, bytes.length);
|
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
|
||||||
exchange.getResponseBody().write(bytes);
|
exchange.getRequestHeaders().getFirst("Authorization"),
|
||||||
exchange.close();
|
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||||
});
|
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||||
|
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.sendResponseHeaders(status, bytes.length);
|
||||||
|
exchange.getResponseBody().write(bytes);
|
||||||
|
exchange.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private GatewayHttpCommands commands(String token) {
|
private GatewayHttpCommands commands(String token) {
|
||||||
@@ -95,6 +106,15 @@ class GatewayHttpCommandsTest {
|
|||||||
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
|
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aHeldChangeSendsTheEndAsUntil() throws Exception {
|
||||||
|
serve(200, contract("response-applied.json"));
|
||||||
|
|
||||||
|
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"), "phone"));
|
||||||
|
|
||||||
|
assertEquals(contractJson("request-held.json"), contractJson(seen.getFirst().body()));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void awayHasNoMinutes() throws Exception {
|
void awayHasNoMinutes() throws Exception {
|
||||||
serve(200, contract("response-applied.json"));
|
serve(200, contract("response-applied.json"));
|
||||||
@@ -136,6 +156,172 @@ class GatewayHttpCommandsTest {
|
|||||||
assertEquals(8, state.restores().getFirst().originalValue());
|
assertEquals(8, state.restores().getFirst().originalValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anUntilChangedRestoreHasNoDue() throws Exception {
|
||||||
|
serve(200, "{\"setpoints\":[],\"restores\":[{\"setpoint\":\"cwu_comfort\",\"register\":4144,"
|
||||||
|
+ "\"originalValue\":45,\"appliedValue\":10,\"leaseUntil\":\"2026-10-24T10:15:00Z\"}]}");
|
||||||
|
|
||||||
|
assertNull(commands("s3cret-token").setpoints().restores().getFirst().due());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restorePostsTheSetpointWithTokenAndUser() throws Exception {
|
||||||
|
serve(200, contract("response-restored.json"));
|
||||||
|
|
||||||
|
var result = commands("s3cret-token").restore("cwu_economy", "anna");
|
||||||
|
|
||||||
|
assertEquals(Outcome.RESTORED, result.outcome());
|
||||||
|
assertEquals("POST", seen.getFirst().method());
|
||||||
|
assertEquals("/commands/restore", seen.getFirst().path());
|
||||||
|
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||||
|
assertEquals("anna", seen.getFirst().requestedBy());
|
||||||
|
assertEquals(contractJson("request-restore.json"), contractJson(seen.getFirst().body()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aConflictOnRestoreIsAVerdictNotAnError() throws Exception {
|
||||||
|
serve(409, contract("response-restore-conflict.json"));
|
||||||
|
|
||||||
|
assertEquals(Outcome.CONFLICT, commands("s3cret-token").restore("cwu_economy", "anna").outcome());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void everyRestoreStatusOfTheGatewayIsAVerdict() throws Exception {
|
||||||
|
var expected = java.util.Map.of(404, Outcome.NOT_PENDING, 429, Outcome.TOO_SOON, 502, Outcome.FAILED);
|
||||||
|
for (var entry : expected.entrySet()) {
|
||||||
|
serve(entry.getKey(), "{\"outcome\":\"" + entry.getValue() + "\",\"message\":\"x\"}");
|
||||||
|
|
||||||
|
assertEquals(entry.getValue(), commands("s3cret-token").restore("cwu_economy", "anna").outcome());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restoreFailsWithoutAVerdictOrToken() {
|
||||||
|
serve(503, "{\"error\":\"x\"}");
|
||||||
|
assertThrows(IOException.class, () -> commands("s3cret-token").restore("cwu_economy", "anna"));
|
||||||
|
|
||||||
|
var seenBefore = seen.size();
|
||||||
|
assertThrows(IOException.class, () -> commands(null).restore("cwu_economy", "anna"));
|
||||||
|
assertEquals(seenBefore, seen.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void renewPostsToRenewWithTheTokenAndReturnsHowManyWereExtended() throws Exception {
|
||||||
|
serve(200, "{\"renewed\":2}");
|
||||||
|
|
||||||
|
assertEquals(2, commands("s3cret-token").renewLeases());
|
||||||
|
|
||||||
|
assertEquals("POST", seen.getFirst().method());
|
||||||
|
assertEquals("/commands/renew", seen.getFirst().path());
|
||||||
|
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void renewFailsOnAnyOtherStatusOrWithoutToken() {
|
||||||
|
serve(503, "{\"error\":\"x\"}");
|
||||||
|
assertThrows(IOException.class, () -> commands("s3cret-token").renewLeases());
|
||||||
|
|
||||||
|
var seenBefore = seen.size();
|
||||||
|
assertThrows(IOException.class, () -> commands(null).renewLeases());
|
||||||
|
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"));
|
||||||
|
|
||||||
|
var reserve = commands("s3cret-token").reserve();
|
||||||
|
|
||||||
|
assertEquals(new BatteryView.Reserve(50, 30), reserve);
|
||||||
|
assertEquals("GET", seen.getFirst().method());
|
||||||
|
assertEquals("/battery/reserve", seen.getFirst().path());
|
||||||
|
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendsAScheduleChangeAsTheGatewayExpectsIt() throws Exception {
|
||||||
|
serve(200, contract("response-battery-applied.json"));
|
||||||
|
var change = new BatteryChange(true, List.of(new BatteryChange.Slot(2, LocalTime.of(9, 30), 90, true),
|
||||||
|
new BatteryChange.Slot(3, null, 85, null)));
|
||||||
|
|
||||||
|
var result = commands("s3cret-token").changeSchedule(change, "anna");
|
||||||
|
|
||||||
|
assertEquals(BatteryResult.Status.APPLIED, result.status());
|
||||||
|
assertEquals(java.util.Map.of(167, 90, 172, 1), result.read());
|
||||||
|
assertEquals("POST", seen.getFirst().method());
|
||||||
|
assertEquals("/battery/schedule", seen.getFirst().path());
|
||||||
|
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||||
|
assertEquals("anna", seen.getFirst().requestedBy());
|
||||||
|
assertEquals(contractJson("request-battery-schedule.json"), contractJson(seen.getFirst().body()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendsAReserveChange() throws Exception {
|
||||||
|
serve(200, contract("response-battery-applied.json"));
|
||||||
|
|
||||||
|
commands("s3cret-token").changeReserve(60, "anna");
|
||||||
|
|
||||||
|
assertEquals("/battery/reserve", seen.getFirst().path());
|
||||||
|
assertEquals(contractJson("{\"pct\":60}"), contractJson(seen.getFirst().body()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void everyBatteryStatusOfTheGatewayIsAVerdict() throws Exception {
|
||||||
|
var expected = java.util.Map.of(422, BatteryResult.Status.REJECTED, 409, BatteryResult.Status.STALE,
|
||||||
|
429, BatteryResult.Status.TOO_SOON, 502, BatteryResult.Status.FAILED);
|
||||||
|
for (var entry : expected.entrySet()) {
|
||||||
|
serve(entry.getKey(), "{\"status\":\"" + entry.getValue() + "\",\"message\":\"x\",\"read\":{}}");
|
||||||
|
|
||||||
|
assertEquals(entry.getValue(), commands("s3cret-token").changeReserve(60, "anna").status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aFailedBatteryWriteCarriesWhatWasRead() throws Exception {
|
||||||
|
serve(502, contract("response-battery-failed.json"));
|
||||||
|
|
||||||
|
var result = commands("s3cret-token").changeSchedule(new BatteryChange(null, null), "anna");
|
||||||
|
|
||||||
|
assertEquals(BatteryResult.Status.FAILED, result.status());
|
||||||
|
assertEquals(java.util.Map.of(167, 80), result.read());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batteryCallsFailWithoutAVerdictOrToken() {
|
||||||
|
for (int status : List.of(400, 401, 404, 503)) {
|
||||||
|
serve(status, "{\"error\":\"x\"}");
|
||||||
|
assertThrows(IOException.class, () -> commands("s3cret-token").reserve(), "GET " + status);
|
||||||
|
assertThrows(IOException.class, () -> commands("s3cret-token").changeReserve(60, "anna"), "POST " + status);
|
||||||
|
}
|
||||||
|
var seenBefore = seen.size();
|
||||||
|
assertThrows(IOException.class, () -> commands(null).reserve());
|
||||||
|
assertThrows(IOException.class, () -> commands(null).changeReserve(60, "anna"));
|
||||||
|
assertEquals(seenBefore, seen.size());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
|
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
|
||||||
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
|
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
|
||||||
|
|||||||
@@ -2,24 +2,43 @@ package home.backend.adapters.http;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import home.backend.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.application.EnergyService;
|
||||||
|
import home.backend.adapters.auth.Authentication;
|
||||||
import home.backend.adapters.config.PresetFile;
|
import home.backend.adapters.config.PresetFile;
|
||||||
import home.backend.application.GatewayClient;
|
import home.backend.application.GatewayClient;
|
||||||
|
import home.backend.application.GatewayBattery;
|
||||||
import home.backend.application.GatewayCommands;
|
import home.backend.application.GatewayCommands;
|
||||||
import home.backend.application.SetpointService;
|
import home.backend.application.SetpointService;
|
||||||
|
import home.backend.domain.BatteryChange;
|
||||||
|
import home.backend.domain.BatteryResult;
|
||||||
|
import home.backend.domain.BatteryView.Reserve;
|
||||||
import home.backend.domain.Command;
|
import home.backend.domain.Command;
|
||||||
import home.backend.domain.CommandResult;
|
import home.backend.domain.CommandResult;
|
||||||
import home.backend.domain.CommandResult.Status;
|
import home.backend.domain.CommandResult.Status;
|
||||||
|
import home.backend.domain.RefreshResult;
|
||||||
|
import home.backend.domain.RestoreResult;
|
||||||
|
import home.backend.domain.RestoreResult.Outcome;
|
||||||
import home.backend.domain.Restore;
|
import home.backend.domain.Restore;
|
||||||
import home.backend.domain.Setpoint;
|
import home.backend.domain.Setpoint;
|
||||||
import home.backend.domain.SetpointState;
|
import home.backend.domain.SetpointState;
|
||||||
import home.backend.application.HealthService;
|
import home.backend.application.HealthService;
|
||||||
import home.backend.application.HeatPumpService;
|
import home.backend.application.HeatPumpService;
|
||||||
import home.backend.application.HistoryRepository;
|
import home.backend.application.HistoryRepository;
|
||||||
|
import home.backend.application.HeatPumpSamples;
|
||||||
import home.backend.application.HistoryService;
|
import home.backend.application.HistoryService;
|
||||||
|
import home.backend.application.StatsService;
|
||||||
|
import home.backend.application.PiSensorService;
|
||||||
|
import home.backend.application.WeatherService;
|
||||||
|
import home.backend.domain.Forecast;
|
||||||
import home.backend.domain.GatewaySnapshot;
|
import home.backend.domain.GatewaySnapshot;
|
||||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||||
|
import home.backend.domain.WeatherView.Warning;
|
||||||
|
import home.backend.domain.HeatPumpSample;
|
||||||
import home.backend.domain.HistoryView.Point;
|
import home.backend.domain.HistoryView.Point;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -35,11 +54,14 @@ import java.net.http.HttpResponse;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
@@ -54,10 +76,18 @@ class ApiServerTest {
|
|||||||
private GatewaySnapshot snapshot;
|
private GatewaySnapshot snapshot;
|
||||||
private boolean gatewayDown;
|
private boolean gatewayDown;
|
||||||
private boolean historyDown;
|
private boolean historyDown;
|
||||||
|
private boolean auditDown;
|
||||||
|
private HeatPumpSamples samples;
|
||||||
private RuntimeException gatewayBug;
|
private RuntimeException gatewayBug;
|
||||||
private final List<Command> sent = new ArrayList<>();
|
private final List<Command> sent = new ArrayList<>();
|
||||||
private Status verdict;
|
private Status verdict;
|
||||||
|
private Outcome restoreOutcome;
|
||||||
|
private int refreshes;
|
||||||
|
private final List<String> restored = new ArrayList<>();
|
||||||
private boolean commandsDown;
|
private boolean commandsDown;
|
||||||
|
private boolean batteryDown;
|
||||||
|
private BatteryResult.Status batteryVerdict;
|
||||||
|
private final List<String> batterySent = new ArrayList<>();
|
||||||
private int answersBeforeDrop;
|
private int answersBeforeDrop;
|
||||||
private ApiServer server;
|
private ApiServer server;
|
||||||
|
|
||||||
@@ -81,7 +111,18 @@ class ApiServerTest {
|
|||||||
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
|
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
|
||||||
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
|
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
|
||||||
};
|
};
|
||||||
|
samples = (from, to) -> {
|
||||||
|
if (historyDown) {
|
||||||
|
throw new IOException("influx down");
|
||||||
|
}
|
||||||
|
return List.of(
|
||||||
|
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 0.0, 8.0, 44.0),
|
||||||
|
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 0.0, 8.5, 43.0),
|
||||||
|
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 100.0, 9.0, 45.0),
|
||||||
|
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 0.0, 9.0, 46.0));
|
||||||
|
};
|
||||||
verdict = Status.APPLIED;
|
verdict = Status.APPLIED;
|
||||||
|
restoreOutcome = Outcome.RESTORED;
|
||||||
answersBeforeDrop = Integer.MAX_VALUE;
|
answersBeforeDrop = Integer.MAX_VALUE;
|
||||||
GatewayCommands commands = new GatewayCommands() {
|
GatewayCommands commands = new GatewayCommands() {
|
||||||
@Override
|
@Override
|
||||||
@@ -98,6 +139,15 @@ class ApiServerTest {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||||
|
if (commandsDown) {
|
||||||
|
throw new IOException("tunnel down");
|
||||||
|
}
|
||||||
|
restored.add(setpoint + " by " + requestedBy);
|
||||||
|
return new RestoreResult(restoreOutcome, "restored to 8");
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SetpointState setpoints() throws IOException {
|
public SetpointState setpoints() throws IOException {
|
||||||
if (commandsDown) {
|
if (commandsDown) {
|
||||||
@@ -109,11 +159,63 @@ class ApiServerTest {
|
|||||||
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)),
|
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)),
|
||||||
List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"))));
|
List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int renewLeases() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
batteryDown = false;
|
||||||
|
batteryVerdict = BatteryResult.Status.APPLIED;
|
||||||
|
GatewayBattery batteryCommands = new GatewayBattery() {
|
||||||
|
@Override
|
||||||
|
public Reserve reserve() throws IOException {
|
||||||
|
if (batteryDown) {
|
||||||
|
throw new IOException("gateway /battery/reserve answered 404");
|
||||||
|
}
|
||||||
|
return new Reserve(50, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
|
||||||
|
return answer("schedule " + change + " by " + requestedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
|
||||||
|
return answer("reserve " + pct + " by " + requestedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BatteryResult answer(String what) throws IOException {
|
||||||
|
if (batteryDown) {
|
||||||
|
throw new IOException("tunnel down");
|
||||||
|
}
|
||||||
|
batterySent.add(what);
|
||||||
|
return switch (batteryVerdict) {
|
||||||
|
case APPLIED -> new BatteryResult(batteryVerdict, "applied", Map.of(167, 90));
|
||||||
|
case FAILED -> new BatteryResult(batteryVerdict,
|
||||||
|
"registers not as written: [167]; not retried", Map.of(167, 80));
|
||||||
|
default -> new BatteryResult(batteryVerdict, "nope", Map.of());
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
|
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
|
||||||
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
|
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
|
||||||
new HealthService(gateway, clock), new HistoryService(history, clock),
|
new EnergyDetailsService(gateway, clock),
|
||||||
new SetpointService(commands, PresetFile.load(null)));
|
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 AuditService(auditRows(), clock), new StatsService(samples, 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();
|
server.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +224,27 @@ class ApiServerTest {
|
|||||||
server.stop();
|
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() {
|
private static GatewaySnapshot exampleSnapshot() {
|
||||||
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
|
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,
|
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
|
||||||
@@ -133,18 +256,46 @@ class ApiServerTest {
|
|||||||
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
|
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
|
||||||
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
|
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
|
||||||
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
|
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
|
||||||
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||||
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
|
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
|
||||||
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
|
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
|
||||||
"load_today_kwh", 24.2);
|
"load_today_kwh", 24.2));
|
||||||
return new GatewaySnapshot(List.of(
|
deye.putAll(Map.of("battery_voltage_v", 56.49, "battery_current_a", -0.05, "battery_temperature_c", 19.2,
|
||||||
|
"battery_charge_today_kwh", 0.5, "battery_discharge_today_kwh", 0.1,
|
||||||
|
"battery_charge_total_kwh", 453.7, "battery_discharge_total_kwh", 162.1));
|
||||||
|
return new GatewaySnapshot("v1.2.1", List.of(
|
||||||
new DeviceSnapshot("heat-pump", true, pumpTime, null,
|
new DeviceSnapshot("heat-pump", true, pumpTime, null,
|
||||||
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
|
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
|
||||||
new DeviceSnapshot("deye", true, energyTime, null,
|
new DeviceSnapshot("deye", true, energyTime, null,
|
||||||
List.of(new Measurement("home", "deye", deye, energyTime))),
|
List.of(new Measurement("home", "deye", deye, energyTime))),
|
||||||
new DeviceSnapshot("fronius", true, energyTime, null,
|
new DeviceSnapshot("fronius", true, energyTime, null,
|
||||||
List.of(new Measurement("home", "fronius",
|
List.of(new Measurement("home", "fronius",
|
||||||
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime)))));
|
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime))),
|
||||||
|
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))),
|
||||||
|
new DeviceSnapshot("deye-settings", true, energyTime, null,
|
||||||
|
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
|
||||||
|
new DeviceSnapshot("bme280", true, energyTime, null,
|
||||||
|
List.of(new Measurement("measurement", "MEASUREMENT",
|
||||||
|
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8), energyTime)))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The settings read from the inverter on 2026-10-24: time of use off, six 4-hour slots. */
|
||||||
|
private static Map<String, Double> deyeSettings() {
|
||||||
|
Map<String, Double> settings = new HashMap<>(Map.of("battery_capacity_ah", 280.0, "tou_enabled", 0.0,
|
||||||
|
"max_charge_current_a", 80.0, "max_discharge_current_a", 120.0, "shutdown_soc_pct", 20.0,
|
||||||
|
"low_soc_pct", 35.0, "restart_soc_pct", 50.0, "grid_charge_enabled", 1.0,
|
||||||
|
"grid_charge_current_a", 40.0, "grid_charge_start_soc_pct", 30.0));
|
||||||
|
double[] starts = {100, 500, 900, 1300, 1700, 2100};
|
||||||
|
for (int slot = 1; slot <= 6; slot++) {
|
||||||
|
settings.put("prog" + slot + "_start_hhmm", starts[slot - 1]);
|
||||||
|
settings.put("prog" + slot + "_power_w", 10000.0);
|
||||||
|
settings.put("prog" + slot + "_soc_pct", 80.0);
|
||||||
|
settings.put("prog" + slot + "_grid_charge", 0.0);
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
private HttpResponse<String> get(String path) throws Exception {
|
private HttpResponse<String> get(String path) throws Exception {
|
||||||
@@ -153,6 +304,10 @@ class ApiServerTest {
|
|||||||
return client.send(request, HttpResponse.BodyHandlers.ofString());
|
return client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String awayRequest() throws IOException {
|
||||||
|
return example("preset-away-request").toString();
|
||||||
|
}
|
||||||
|
|
||||||
private static JsonNode example(String name) throws IOException {
|
private static JsonNode example(String name) throws IOException {
|
||||||
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
|
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
|
||||||
return JSON.readTree(in);
|
return JSON.readTree(in);
|
||||||
@@ -178,16 +333,21 @@ class ApiServerTest {
|
|||||||
assertMatchesExample("/api/energy", "energy");
|
assertMatchesExample("/api/energy", "energy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void energyDetailsMatchExample() throws Exception {
|
||||||
|
assertMatchesExample("/api/energy/details", "energy-details");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void healthMatchesExample() throws Exception {
|
void healthMatchesExample() throws Exception {
|
||||||
snapshot = new GatewaySnapshot(List.of(snapshot.devices().get(0),
|
snapshot = new GatewaySnapshot("v1.2.1", List.of(snapshot.devices().get(0),
|
||||||
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
|
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
|
||||||
assertMatchesExample("/api/health", "health");
|
assertMatchesExample("/api/health", "health");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
|
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
|
||||||
snapshot = new GatewaySnapshot(List.of());
|
snapshot = new GatewaySnapshot("v1.2.1", List.of());
|
||||||
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
|
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,25 +358,204 @@ class ApiServerTest {
|
|||||||
Measurement deye = devices.get(1).measurements().get(0);
|
Measurement deye = devices.get(1).measurements().get(0);
|
||||||
devices.set(1, new DeviceSnapshot("deye", true, old, null,
|
devices.set(1, new DeviceSnapshot("deye", true, old, null,
|
||||||
List.of(new Measurement("home", "deye", deye.fields(), old))));
|
List.of(new Measurement("home", "deye", deye.fields(), old))));
|
||||||
snapshot = new GatewaySnapshot(devices);
|
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||||
assertMatchesExample("/api/energy", "energy-deye-stale");
|
assertMatchesExample("/api/energy", "energy-deye-stale");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batteryMatchesExample() throws Exception {
|
||||||
|
assertMatchesExample("/api/battery", "battery");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batteryWithStaleSettingsMatchesExample() throws Exception {
|
||||||
|
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||||
|
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||||
|
devices.set(devices.size() - 2, new DeviceSnapshot("deye-settings", true, old, null,
|
||||||
|
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), old))));
|
||||||
|
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||||
|
assertMatchesExample("/api/battery", "battery-settings-stale");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batteryWithoutGatewayCommandsIsNotEditable() throws Exception {
|
||||||
|
batteryDown = true;
|
||||||
|
|
||||||
|
var body = JSON.readTree(get("/api/battery").body());
|
||||||
|
|
||||||
|
assertTrue(body.get("reserve").isNull());
|
||||||
|
assertEquals(false, body.get("editable").asBoolean());
|
||||||
|
assertEquals(99.0, body.get("socPct").asDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
|
||||||
|
var response = post("/api/battery/schedule", example("battery-schedule-request").toString());
|
||||||
|
|
||||||
|
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 phone"), batterySent);
|
||||||
|
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
|
||||||
|
JSON.readTree(response.body()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reserveChangeGoesToTheGateway() throws Exception {
|
||||||
|
var response = post("/api/battery/reserve", example("battery-reserve-request").toString());
|
||||||
|
|
||||||
|
assertEquals(200, response.statusCode());
|
||||||
|
assertEquals(List.of("reserve 60 by phone"), batterySent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batteryStatusFollowsTheGatewaysVerdict() throws Exception {
|
||||||
|
var expected = Map.of(BatteryResult.Status.REJECTED, 422, BatteryResult.Status.STALE, 409,
|
||||||
|
BatteryResult.Status.TOO_SOON, 429, BatteryResult.Status.FAILED, 502);
|
||||||
|
for (var entry : expected.entrySet()) {
|
||||||
|
batteryVerdict = entry.getKey();
|
||||||
|
|
||||||
|
assertEquals(entry.getValue(), post("/api/battery/reserve", "{\"pct\":60}").statusCode());
|
||||||
|
assertEquals(entry.getValue(), post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
|
||||||
|
}
|
||||||
|
batteryVerdict = BatteryResult.Status.FAILED;
|
||||||
|
assertEquals(example("battery-failed"),
|
||||||
|
JSON.readTree(post("/api/battery/schedule", "{\"enabled\":false}").body()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batteryChangeWithTheGatewayDownIs503() throws Exception {
|
||||||
|
batteryDown = true;
|
||||||
|
|
||||||
|
assertEquals(503, post("/api/battery/reserve", "{\"pct\":60}").statusCode());
|
||||||
|
assertEquals(503, post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void badBatteryRequestsAre400AndSendNothing() throws Exception {
|
||||||
|
for (String body : List.of("", "not json", "[]", "{}", "{\"pct\":\"60\"}", "{\"pct\":60.5}", "{\"pct\":null}",
|
||||||
|
"{\"pct\":60,\"x\":1}")) {
|
||||||
|
assertEquals(400, post("/api/battery/reserve", body).statusCode(), body);
|
||||||
|
}
|
||||||
|
for (String body : List.of("", "not json", "[]", "{\"enabled\":\"yes\"}", "{\"slots\":[{\"slot\":1,\"from\":\"9h\"}]}",
|
||||||
|
"{\"slots\":[{\"slot\":1,\"from\":\"25:00\"}]}", "{\"slots\":[{\"slot\":1,\"socPct\":\"9\"}]}",
|
||||||
|
"{\"slots\":[{\"slot\":1,\"soc\":9}]}", "{\"slots\":[{\"slot\":1,\"gridCharge\":1}]}")) {
|
||||||
|
assertEquals(400, post("/api/battery/schedule", body).statusCode(), body);
|
||||||
|
}
|
||||||
|
assertTrue(batterySent.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batteryWritesAreGuardedLikeEveryWrite() throws Exception {
|
||||||
|
for (String path : List.of("/api/battery/reserve", "/api/battery/schedule")) {
|
||||||
|
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode(), path);
|
||||||
|
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode(), path);
|
||||||
|
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode(), path);
|
||||||
|
assertEquals(405, get(path).statusCode(), path);
|
||||||
|
}
|
||||||
|
assertEquals(404, post("/api/battery/other", "{}").statusCode());
|
||||||
|
assertTrue(batterySent.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Forecast exampleForecast() {
|
||||||
|
Map<LocalDate, Forecast.DailyForecast> days = new TreeMap<>();
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
days.put(LocalDate.parse("2026-10-19").plusDays(i), new Forecast.DailyForecast(3, 12.0, 4.0, 0.0, 10.0, 7200.0));
|
||||||
|
}
|
||||||
|
days.put(LocalDate.parse("2026-10-24"), new Forecast.DailyForecast(3, 10.9, 3.8, 0.0, 9.0, 7151.0));
|
||||||
|
days.put(LocalDate.parse("2026-10-25"), new Forecast.DailyForecast(51, 13.2, 4.0, 0.3, 4.5, 28314.0));
|
||||||
|
days.put(LocalDate.parse("2026-10-26"), new Forecast.DailyForecast(61, 9.6, 4.1, 8.4, 2.1, 0.0));
|
||||||
|
return new Forecast(10.6, days);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Twice the forecast radiation of the same days, so the factor is 2 kWh per MJ/m². */
|
||||||
|
private static Map<LocalDate, Double> exampleMeasuredPv() {
|
||||||
|
Map<LocalDate, Double> measured = new HashMap<>();
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
measured.put(LocalDate.parse("2026-10-19").plusDays(i), 20.0);
|
||||||
|
}
|
||||||
|
return measured;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Warning exampleWarning() {
|
||||||
|
return new Warning("Silny wiatr", 2, OffsetDateTime.parse("2026-10-25T08:00:00+01:00"),
|
||||||
|
OffsetDateTime.parse("2026-10-25T18:00:00+01:00"),
|
||||||
|
"Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void weatherMatchesExample() throws Exception {
|
||||||
|
assertMatchesExample("/api/weather", "weather");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void piSensorMatchesExample()throws Exception {
|
||||||
|
assertMatchesExample("/api/pi-sensor", "pi-sensor");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stalePiSensorMatchesExample() throws Exception {
|
||||||
|
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||||
|
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||||
|
Measurement bme = devices.getLast().measurements().get(0);
|
||||||
|
devices.set(devices.size() - 1, new DeviceSnapshot("bme280", true, old, null,
|
||||||
|
List.of(new Measurement(bme.database(), bme.measurement(), bme.fields(), old))));
|
||||||
|
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||||
|
assertMatchesExample("/api/pi-sensor", "pi-sensor-stale");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void historyMatchesExample() throws Exception {
|
void historyMatchesExample() throws Exception {
|
||||||
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
|
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
|
@Test
|
||||||
void historyRangeDefaultsTo24Hours() throws Exception {
|
void historyRangeDefaultsTo24Hours() throws Exception {
|
||||||
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Test
|
||||||
void unreachableGatewayIs503WithJsonError() throws Exception {
|
void unreachableGatewayIs503WithJsonError() throws Exception {
|
||||||
gatewayDown = true;
|
gatewayDown = true;
|
||||||
|
|
||||||
for (String path : List.of("/api/heat-pump", "/api/energy")) {
|
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/energy/details")) {
|
||||||
HttpResponse<String> response = get(path);
|
HttpResponse<String> response = get(path);
|
||||||
assertEquals(503, response.statusCode());
|
assertEquals(503, response.statusCode());
|
||||||
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
|
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
|
||||||
@@ -301,6 +640,78 @@ class ApiServerTest {
|
|||||||
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
|
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
|
||||||
|
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}");
|
||||||
|
|
||||||
|
assertEquals(200, response.statusCode());
|
||||||
|
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
|
||||||
|
assertEquals(List.of("cwu_economy by phone"), restored);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restoreStatusFollowsTheGatewaysVerdict() throws Exception {
|
||||||
|
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
|
||||||
|
Outcome.FAILED, 502);
|
||||||
|
for (var entry : expected.entrySet()) {
|
||||||
|
restoreOutcome = entry.getKey();
|
||||||
|
|
||||||
|
assertEquals(entry.getValue(), post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restoreWithTheGatewayDownIs503() throws Exception {
|
||||||
|
commandsDown = true;
|
||||||
|
|
||||||
|
assertEquals(503, post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restoreIsGuardedLikeEveryWrite() throws Exception {
|
||||||
|
String path = "/api/setpoints/cwu_economy/restore";
|
||||||
|
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
|
||||||
|
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
|
||||||
|
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
|
||||||
|
assertEquals(405, get(path).statusCode());
|
||||||
|
assertTrue(restored.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 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",
|
||||||
|
"/api/setpoints/cwu_economy/restore/x", "/api/setpoints/CWU/restore", "/api/setpoints/a-b/restore")) {
|
||||||
|
assertEquals(404, post(path, "{}").statusCode(), path);
|
||||||
|
}
|
||||||
|
assertTrue(restored.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectedIs422AndMatchesExample() throws Exception {
|
void rejectedIs422AndMatchesExample() throws Exception {
|
||||||
verdict = Status.REJECTED;
|
verdict = Status.REJECTED;
|
||||||
@@ -319,12 +730,10 @@ class ApiServerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception {
|
void aRemoteUserHeaderIsNotTrusted() throws Exception {
|
||||||
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl");
|
post("/api/setpoints", BOOST, "Remote-User", "anna");
|
||||||
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
|
|
||||||
post("/api/setpoints", BOOST);
|
|
||||||
|
|
||||||
assertEquals(List.of("anna.k@example.pl", "phone", "phone"), sent.stream().map(Command::requestedBy).toList());
|
assertEquals(List.of("phone"), sent.stream().map(Command::requestedBy).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -343,7 +752,7 @@ class ApiServerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void presetResultsMatchExample() throws Exception {
|
void presetResultsMatchExample() throws Exception {
|
||||||
HttpResponse<String> response = post("/api/presets/away", "");
|
HttpResponse<String> response = post("/api/presets/away", awayRequest());
|
||||||
|
|
||||||
assertEquals(200, response.statusCode());
|
assertEquals(200, response.statusCode());
|
||||||
JsonNode body = JSON.readTree(response.body());
|
JsonNode body = JSON.readTree(response.body());
|
||||||
@@ -356,12 +765,66 @@ class ApiServerTest {
|
|||||||
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
|
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
|
||||||
answersBeforeDrop = 1;
|
answersBeforeDrop = 1;
|
||||||
|
|
||||||
HttpResponse<String> response = post("/api/presets/away", "{}");
|
HttpResponse<String> response = post("/api/presets/away", awayRequest());
|
||||||
|
|
||||||
assertEquals(502, response.statusCode());
|
assertEquals(502, response.statusCode());
|
||||||
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
|
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void awayPresetSendsTheChosenEndHeldInEachChange() throws Exception {
|
||||||
|
post("/api/presets/away", awayRequest());
|
||||||
|
|
||||||
|
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
|
||||||
|
assertEquals(List.of(new Command("cwu_comfort", 10, null, end, "phone"),
|
||||||
|
new Command("co_comfort", 15, null, end, "phone")), sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void awayWithOwnValuesSendsThem() throws Exception {
|
||||||
|
var response = post("/api/presets/away", example("preset-away-values-request").toString());
|
||||||
|
|
||||||
|
assertEquals(200, response.statusCode());
|
||||||
|
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
|
||||||
|
assertEquals(List.of(new Command("cwu_comfort", 12, null, end, "phone"),
|
||||||
|
new Command("co_comfort", 16, null, end, "phone")), sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ownValuesForOtherSetpointsOrOutsideTheLimitsAre400AndSendNothing() throws Exception {
|
||||||
|
String until = "\"until\":\"2026-11-02T18:00:00Z\"";
|
||||||
|
for (String values : List.of("{\"cwu_economy\":6}", "{\"co_comfort\":4}", "{\"co_comfort\":26}",
|
||||||
|
"{\"co_comfort\":null}", "{\"co_comfort\":15.5}", "{\"co_comfort\":\"15\"}", "[]")) {
|
||||||
|
assertEquals(400, post("/api/presets/away", "{" + until + ",\"values\":" + values + "}").statusCode(),
|
||||||
|
values);
|
||||||
|
}
|
||||||
|
assertTrue(sent.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ownValueThatDoesNotLowerAHeldSetpointIs422() throws Exception {
|
||||||
|
var body = "{\"until\":\"2026-11-02T18:00:00Z\",\"values\":{\"co_comfort\":19}}";
|
||||||
|
|
||||||
|
assertEquals(422, post("/api/presets/away", body).statusCode());
|
||||||
|
assertTrue(sent.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void badOrMissingEndTimesAre400AndSendNothing() throws Exception {
|
||||||
|
for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}",
|
||||||
|
"{\"until\":\"2026-11-02T18:00:00\"}", "not json", "{\"until\":\"2026-11-02T18:00:00Z\",\"x\":1}")) {
|
||||||
|
assertEquals(400, post("/api/presets/away", body).statusCode(), body);
|
||||||
|
}
|
||||||
|
assertEquals(400, post("/api/presets/cwu-now", awayRequest()).statusCode());
|
||||||
|
assertTrue(sent.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void presetWithoutAnEndStillTakesNoBodyOrAnEmptyObject() throws Exception {
|
||||||
|
assertEquals(200, post("/api/presets/cwu-now", "").statusCode());
|
||||||
|
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unknownPresetIs404() throws Exception {
|
void unknownPresetIs404() throws Exception {
|
||||||
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
||||||
@@ -372,7 +835,7 @@ class ApiServerTest {
|
|||||||
void presetOutsideLimitsIs422() throws Exception {
|
void presetOutsideLimitsIs422() throws Exception {
|
||||||
verdict = Status.REJECTED;
|
verdict = Status.REJECTED;
|
||||||
|
|
||||||
assertEquals(422, post("/api/presets/away", "").statusCode());
|
assertEquals(422, post("/api/presets/away", awayRequest()).statusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -381,7 +844,7 @@ class ApiServerTest {
|
|||||||
|
|
||||||
assertEquals(503, get("/api/setpoints").statusCode());
|
assertEquals(503, get("/api/setpoints").statusCode());
|
||||||
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
|
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
|
||||||
assertEquals(503, post("/api/presets/away", "").statusCode());
|
assertEquals(503, post("/api/presets/away", awayRequest()).statusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package home.backend.adapters.influx;
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import home.backend.application.AuditRepository.Row;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class InfluxAuditRepositoryTest {
|
||||||
|
|
||||||
|
private static final Instant FROM = Instant.parse("2026-10-17T12:00:00Z");
|
||||||
|
private static final Instant TO = Instant.parse("2026-10-24T12:00:00Z");
|
||||||
|
|
||||||
|
private HttpServer server;
|
||||||
|
private final List<String> queries = Collections.synchronizedList(new ArrayList<>());
|
||||||
|
private String body;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void startServer() throws IOException {
|
||||||
|
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||||
|
server.createContext("/query", exchange -> {
|
||||||
|
queries.add(URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8));
|
||||||
|
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.sendResponseHeaders(200, bytes.length);
|
||||||
|
exchange.getResponseBody().write(bytes);
|
||||||
|
exchange.close();
|
||||||
|
});
|
||||||
|
server.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stopServer() {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsHeatPumpRowsFromTheLegacyDatabase() throws IOException {
|
||||||
|
body = fixture("influx-audit-setpoints.json");
|
||||||
|
|
||||||
|
List<Row> rows = repository().heatPump(FROM, TO);
|
||||||
|
|
||||||
|
assertEquals(2, rows.size());
|
||||||
|
assertEquals(new Row(Instant.ofEpochMilli(1761292800000L), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
|
||||||
|
rows.get(0));
|
||||||
|
assertNull(rows.get(1).oldValue());
|
||||||
|
assertEquals("co_comfort", rows.get(1).target());
|
||||||
|
assertTrue(queries.getFirst().startsWith("db=Dom_Pompa&epoch=ms&q=SELECT * FROM \"setpoint_audit\" "
|
||||||
|
+ "WHERE time >= " + FROM.toEpochMilli() + "ms AND time < " + TO.toEpochMilli() + "ms"),
|
||||||
|
queries.getFirst());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsBatteryRowsByRegisterTag() throws IOException {
|
||||||
|
body = fixture("influx-audit-battery.json");
|
||||||
|
|
||||||
|
List<Row> rows = repository().battery(FROM, TO);
|
||||||
|
|
||||||
|
assertEquals(List.of("reserve", "174"), rows.stream().map(Row::target).toList());
|
||||||
|
assertEquals(60.0, rows.getFirst().newValue());
|
||||||
|
assertTrue(queries.getFirst().startsWith("db=electricity&"), queries.getFirst());
|
||||||
|
assertTrue(queries.getFirst().contains("\"deye_settings_audit\""), queries.getFirst());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noRowsIsAnEmptyList() throws IOException {
|
||||||
|
body = fixture("influx-query-empty.json");
|
||||||
|
|
||||||
|
assertTrue(repository().battery(FROM, TO).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anInfluxErrorIsAnIOException() throws IOException {
|
||||||
|
body = fixture("influx-query-error.json");
|
||||||
|
|
||||||
|
assertThrows(IOException.class, () -> repository().heatPump(FROM, TO));
|
||||||
|
}
|
||||||
|
|
||||||
|
private InfluxAuditRepository repository() {
|
||||||
|
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||||
|
return new InfluxAuditRepository(new InfluxSettings(base, Duration.ofSeconds(5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fixture(String name) throws IOException {
|
||||||
|
return Files.readString(Path.of("src/test/resources/influx", name));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package home.backend.adapters.influx;
|
package home.backend.adapters.influx;
|
||||||
|
|
||||||
import com.sun.net.httpserver.HttpServer;
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import home.backend.domain.HeatPumpSample;
|
||||||
import home.backend.domain.HistoryView.Point;
|
import home.backend.domain.HistoryView.Point;
|
||||||
import home.backend.domain.Series;
|
import home.backend.domain.Series;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
@@ -208,6 +209,35 @@ class InfluxHistoryRepositoryTest {
|
|||||||
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
|
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsRawHeatPumpPollsFromTheLegacyDatabase() throws IOException {
|
||||||
|
defaultBody = fixture("influx-query-heat-pump.json");
|
||||||
|
|
||||||
|
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
|
||||||
|
|
||||||
|
assertEquals("Dom_Pompa", requests.get(0).get("db"));
|
||||||
|
assertEquals("SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
|
||||||
|
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
|
||||||
|
requests.get(0).get("q"));
|
||||||
|
assertEquals(3, samples.size());
|
||||||
|
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 0.0, 8.5, 43.9),
|
||||||
|
samples.get(1));
|
||||||
|
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null),
|
||||||
|
samples.get(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noHeatPumpPollsIsAnEmptyList() throws IOException {
|
||||||
|
assertTrue(repository(TIMEOUT).samples(FROM, TO).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void heatPumpQueryErrorIsAnIoException() throws IOException {
|
||||||
|
defaultBody = fixture("influx-query-error.json");
|
||||||
|
|
||||||
|
assertThrows(IOException.class, () -> repository(TIMEOUT).samples(FROM, TO));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsAnEmptyRange() {
|
void rejectsAnEmptyRange() {
|
||||||
assertThrows(IllegalArgumentException.class,
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user