Compare commits
199
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
93a3582c45 | ||
|
|
649751a0d3 | ||
|
|
1e5c40b5d0 | ||
|
|
b2b946d4bc | ||
|
|
4afc1c9ed0 | ||
|
|
5d8d31c8e1 | ||
|
|
3d47764c2f | ||
|
|
36093eb8dc | ||
|
|
074bd7b78e | ||
|
|
d4a9f2fa25 | ||
|
|
49b5689b96 | ||
|
|
b17a84e3dd | ||
|
|
7402a1d6a2 | ||
|
|
ab4fdef6c8 | ||
|
|
f504df7e88 | ||
|
|
ce1c733b9f | ||
|
|
00755cc560 | ||
|
|
f7c36ab1f6 | ||
|
|
c4b4715699 | ||
|
|
bc47260e86 | ||
|
|
2d62ac1d87 | ||
|
|
fce624b822 | ||
|
|
af51ed0626 | ||
|
|
088de5ac1d | ||
|
|
6ee291b094 | ||
|
|
e95f9b89f1 | ||
|
|
43e9086689 | ||
|
|
d74fdbde34 | ||
|
|
e3c27475ba | ||
|
|
85600d70ef | ||
|
|
b1fce8ad31 | ||
|
|
39b682f2ff | ||
|
|
80c66461bf | ||
|
|
d7f27af997 | ||
|
|
7cbbc788ef | ||
|
|
e901d4c351 | ||
|
|
7263c9e3c3 | ||
|
|
5dacbfd45e | ||
|
|
b06dde4587 | ||
|
|
2c214c889d | ||
|
|
0c0ca68fbf | ||
|
|
17b65dc1c0 | ||
|
|
65f96d3f41 | ||
|
|
a1a0853d65 | ||
|
|
5f2dc81107 | ||
|
|
6d7704964a | ||
|
|
0f7939fa6e | ||
|
|
a36ef14f0b | ||
|
|
a336851cd7 | ||
|
|
b74ada8cb6 | ||
|
|
070b61c785 | ||
|
|
b58fe88ee0 | ||
|
|
f29728cb9f | ||
|
|
b7f62d95aa | ||
|
|
d010c51e2a | ||
|
|
5073e0a1b3 | ||
|
|
377349cf0e | ||
|
|
2b6cfd3496 | ||
|
|
79cf69eed3 | ||
|
|
af6f829da9 | ||
|
|
6f2f7cdfa0 | ||
|
|
daa8de3235 | ||
|
|
cfabd96bb2 | ||
|
|
5e0939b171 | ||
|
|
ff1dce8588 | ||
|
|
8829372f63 | ||
|
|
38dbcfe1db | ||
|
|
da2d2e30c6 | ||
|
|
70dabd6bce | ||
|
|
1c623d496c | ||
|
|
3603491617 | ||
|
|
09fb9ad6e4 | ||
|
|
ef3662df1b | ||
|
|
38c023c493 | ||
|
|
422258de72 | ||
|
|
404aa3c367 | ||
|
|
1027410383 | ||
|
|
6348726b82 | ||
|
|
cd4259b175 | ||
|
|
a7a6dd2caf | ||
|
|
e56c1cdad6 | ||
|
|
13770b44ce | ||
|
|
82ff6565ad | ||
|
|
a6e6f36e87 | ||
|
|
e835f1adfa | ||
|
|
098be6af19 | ||
|
|
322f4834a8 | ||
|
|
d8a9716192 | ||
|
|
d6531054ec | ||
|
|
01fb331e22 | ||
|
|
15a3fc953e | ||
|
|
e591c125cc | ||
|
|
5b75c3b181 | ||
|
|
08650bd0f7 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: ship
|
||||
description: Take the current topic branch to master through a Gitea PR with green CI, then optionally deploy the gateway to the Pi. Use when a change is ready to merge or release.
|
||||
description: Take the current topic branch to master through a Gitea PR with green CI, then optionally release it and update the Pi. Use when a change is ready to merge or release.
|
||||
---
|
||||
|
||||
# Ship
|
||||
@@ -23,8 +23,10 @@ Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/
|
||||
`git switch master && git pull --ff-only && git merge --no-ff <branch> -m "Merge PR #N: <title>"`,
|
||||
then `git push origin master` and delete the branch locally and on origin. Confirm CI is
|
||||
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
|
||||
6. Deploy only with the user's explicit approval for this deploy, and only for changes that
|
||||
touch the gateway: `scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor
|
||||
for features; `PI=<ssh host>` while D7 is open). Then watch
|
||||
`ssh <host> journalctl -u home-pump.service -f`
|
||||
and Grafana for one polling cycle (300 s) and report what you saw.
|
||||
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) waits for green
|
||||
CI on master's head (no local check run), then tags master;
|
||||
wait until the Release run under `…/actions` is green (it publishes the images). Then on the
|
||||
Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`,
|
||||
watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and
|
||||
report what you saw. Backend and frontend reach Kraków through Arcane (homelab-compose).
|
||||
+10
-9
@@ -1,3 +1,6 @@
|
||||
# One job: check.sh builds the three parts in parallel itself. The runner runs one job at a time
|
||||
# and its actions cache server is unreachable from job containers (ETIMEDOUT), so separate jobs
|
||||
# or setup-java/setup-node caches would only add waiting.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
@@ -6,20 +9,14 @@ on:
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests (Maven / Java 21)
|
||||
check:
|
||||
name: Check (gateway, backend, frontend)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Show runner info
|
||||
run: |
|
||||
echo "Runner name: ${{ runner.name }}"
|
||||
echo "Runner labels: ${{ toJSON(runner.labels) }}"
|
||||
|
||||
- name: Set up Java 21 + Maven
|
||||
- name: Set up Java 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -28,5 +25,9 @@ jobs:
|
||||
uses: stCarolas/setup-maven@v5
|
||||
with:
|
||||
maven-version: 3.9.6
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Check
|
||||
run: scripts/check.sh
|
||||
@@ -0,0 +1,56 @@
|
||||
# A pushed tag vMAJOR.MINOR.PATCH (scripts/release.sh) publishes the three images under that name
|
||||
# and as latest. The Pi pulls the gateway with gateway/update.sh; Kraków runs backend and frontend.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
env:
|
||||
REGISTRY: git.mariuszpacek.pl
|
||||
IMAGES: git.mariuszpacek.pl/mariusz.pacek/home-pump
|
||||
|
||||
jobs:
|
||||
images:
|
||||
name: Image (${{ matrix.part }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into
|
||||
# the final stage, so the arm64 image needs no emulation.
|
||||
- part: gateway
|
||||
platforms: linux/arm64,linux/amd64
|
||||
- part: backend
|
||||
platforms: linux/amd64
|
||||
- part: frontend
|
||||
platforms: linux/amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# As in homelab-upgrade: the job talks to VM103's daemon through the mounted socket.
|
||||
- name: Docker CLI
|
||||
run: command -v docker >/dev/null || (apt-get update -qq && apt-get install -y -qq docker.io >/dev/null); docker version --format '{{.Client.Version}} -> {{.Server.Version}}'
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to the Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.repository_owner }}
|
||||
# The owner's token with package write access (the secret homelab-upgrade uses too).
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
# The registry cache keeps the Maven and npm layers between releases.
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ matrix.part }}
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-${{ matrix.part }}:${{ gitea.ref_name }},${{ env.IMAGES }}-${{ matrix.part }}:latest
|
||||
cache-from: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache,mode=max
|
||||
+1
-2
@@ -18,7 +18,7 @@ target/
|
||||
/.idea/
|
||||
/.vscode/
|
||||
/.java-version
|
||||
/dependency-reduced-pom.xml
|
||||
dependency-reduced-pom.xml
|
||||
/workspace.code-workspace
|
||||
/target/
|
||||
|
||||
@@ -26,7 +26,6 @@ target/
|
||||
/node_modules/
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/frontend/.env*
|
||||
/dist/
|
||||
|
||||
# Vite
|
||||
|
||||
@@ -6,8 +6,9 @@ Backend for controlling the heat pump in Krosno (Frisko controller, Modbus TCP)
|
||||
|
||||
- Start from `docs/roadmap.md`; stay within the current milestone unless asked.
|
||||
- Read `docs/spec.md` and a decision in `docs/decisions/` only when the task touches it.
|
||||
- Production: `pi@raspberrypi.local`, service `home-pump.service`. Read-only inspection is fine;
|
||||
deploy, restart or file changes on the Pi need explicit approval each time.
|
||||
- Production: `pi@raspberrypi.local`, the gateway in Docker (`gateway/compose.yaml`; until it is
|
||||
installed, the old `home-pump.service`). Read-only inspection is fine; installing, updating,
|
||||
restarting or changing files on the Pi needs explicit approval each time.
|
||||
- The Pi also runs `rpi-measurements.service` (house meter and BME280 into Influx); do not break
|
||||
it. It moves into the gateway in M1; never run both against the meter at once. Do not upgrade
|
||||
or reinstall the Pi's OS.
|
||||
|
||||
+7
-5
@@ -19,7 +19,8 @@ Reasons: [docs/decisions/](docs/decisions/).
|
||||
|
||||
## Code
|
||||
|
||||
- Java 21, Maven, no framework (ADR-001). `mvn clean package`, `mvn test`.
|
||||
- Three parts, each with its `Dockerfile` and `compose.yaml` (ADR-010): `gateway/` and `backend/`
|
||||
in Java 21 with Maven and no framework (ADR-001), `frontend/` in React and TypeScript (ADR-008).
|
||||
- `scripts/check.sh` is the gate: doc limits, secret scan, build and tests. CI runs it, and so
|
||||
does the pre-push hook once enabled with `git config core.hooksPath .githooks`.
|
||||
- Readable first: small classes and methods, names from the domain, comments only for the why.
|
||||
@@ -54,7 +55,8 @@ Docs are written so an agent needs little context: read only what the task touch
|
||||
|
||||
## Configuration, secrets, releases
|
||||
|
||||
- No secrets in git. Production config lives on the Pi, outside the jar.
|
||||
- Deploy only from `master` with `scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH`; it tags the
|
||||
deploy and rolls back if the service does not come up healthy.
|
||||
- After a deploy, watch the journal and Grafana for at least one polling cycle.
|
||||
- No secrets in git. Production config lives on the Pi and next to the Kraków stacks, outside
|
||||
the images.
|
||||
- Release only from `master` with `scripts/release.sh vMAJOR.MINOR.PATCH`: it tags master and
|
||||
CI publishes the three images. On the Pi `./update.sh <version>` goes back if it is unhealthy.
|
||||
- After an update, watch the journal and Grafana for at least one polling cycle.
|
||||
@@ -7,29 +7,19 @@ Heat pump (Frisko RX910-SE21) and home energy for the house in Krosno, from a ph
|
||||
- Rules for changes: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- Controller knowledge: [docs/frisko/](docs/frisko/rx910-se21.md)
|
||||
|
||||
```bash
|
||||
scripts/check.sh # docs, secret scan, build, tests
|
||||
git config core.hooksPath .githooks # once per clone: check before every push
|
||||
scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH # deploy master to the Pi (approval needed)
|
||||
```
|
||||
|
||||
## Backend (phone API and web UI)
|
||||
|
||||
Settings are environment variables; the backend refuses to start without the two URLs.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| Part | Runs | Readme |
|
||||
|---|---|---|
|
||||
| `GATEWAY_URL` | required | the gateway on the Pi, e.g. `http://10.0.0.2:8080` |
|
||||
| `INFLUX_URL` | required | InfluxDB 1.x, e.g. `https://influxdb.example` |
|
||||
| `PORT` | `8080` | listening port |
|
||||
| `GATEWAY_TIMEOUT` | `5` | seconds |
|
||||
| `INFLUX_TIMEOUT` | `10` | seconds |
|
||||
| `gateway/` | Pi in Krosno, Docker: devices to InfluxDB, `/commands` | [gateway/README.md](gateway/README.md) |
|
||||
| `backend/` | Kraków, Docker: phone API | [backend/README.md](backend/README.md) |
|
||||
| `frontend/` | Kraków, Docker: web UI with nginx | [frontend/README.md](frontend/README.md) |
|
||||
|
||||
Each part has its `Dockerfile` and `compose.yaml` (ADR-010).
|
||||
|
||||
```bash
|
||||
mvn -f backend/pom.xml clean package
|
||||
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
|
||||
java -jar backend/target/home-pump-backend-1.0.0.jar # UI at http://localhost:8080/
|
||||
|
||||
docker build -t home-pump-backend backend # after the mvn package above
|
||||
docker run --rm -p 8080:8080 -e GATEWAY_URL=... -e INFLUX_URL=... home-pump-backend
|
||||
scripts/check.sh # docs, secret scan, build and tests of all three parts
|
||||
git config core.hooksPath .githooks # once per clone: check before every push
|
||||
scripts/release.sh vMAJOR.MINOR.PATCH # tag master; CI publishes the three images in Gitea
|
||||
```
|
||||
|
||||
After a release: on the Pi `./update.sh <version>` (gateway/README.md); in Kraków Arcane runs the
|
||||
backend and frontend images.
|
||||
@@ -0,0 +1 @@
|
||||
target
|
||||
+14
-8
@@ -1,16 +1,22 @@
|
||||
# Build the jar first: mvn -f backend/pom.xml clean package
|
||||
# Built on the build machine's own platform; the image only holds the jar and a JRE.
|
||||
FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21 AS build
|
||||
WORKDIR /src
|
||||
COPY pom.xml .
|
||||
RUN mvn -B -q dependency:go-offline
|
||||
COPY src src
|
||||
# Tests run in scripts/check.sh; some read contract files from gateway/, outside this context.
|
||||
RUN mvn -B -q package -Dmaven.test.skip=true
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
|
||||
RUN adduser -D -u 10001 app
|
||||
USER app
|
||||
# The release tag, shown in /api/health; the release workflow passes it.
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=$VERSION
|
||||
USER 10001:10001
|
||||
WORKDIR /app
|
||||
COPY 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
|
||||
|
||||
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body),
|
||||
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine.
|
||||
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
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
@@ -0,0 +1,91 @@
|
||||
# Backend
|
||||
|
||||
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
|
||||
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
|
||||
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
|
||||
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
|
||||
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
|
||||
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
|
||||
the three values are null (`pi-sensor-stale.json`).
|
||||
|
||||
`GET /api/stats?range=7d|30d` (example `src/test/resources/api/stats.json`; 400 otherwise, 502 when InfluxDB is down) is the heat pump per local
|
||||
day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` with `date`, `compressorHours`, `compressorStarts`, `cwuHours`, `coHours`,
|
||||
`heaterHours`, `avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
|
||||
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
|
||||
|
||||
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
|
||||
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
|
||||
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
|
||||
`mode` (`touEnabled` with a Polish sentence), `schedule` (six slots, each from its start to the next one, the last wraps past midnight),
|
||||
`limits`, and `capacityKwh` = capacity in Ah x a nominal 51.2 V, since the inverter reports Ah only. When stale they are null (`schedule` empty).
|
||||
|
||||
`/api/battery` also has `reserve` (`pct`, `floorPct`: the backup reserve of the app and the lowest the user may set) and `editable`:
|
||||
true when the gateway answers its `/battery/reserve`, i.e. it has battery commands on and `GATEWAY_TOKEN` is right; else `reserve` is null
|
||||
and `editable` false. `POST /api/battery/schedule` (examples `battery-schedule-request.json`, `battery-failed.json`) takes
|
||||
`{"enabled"?, "slots": [{"slot": 1-6, "from"?: "HH:MM", "socPct"?, "gridCharge"?}]}` and `POST /api/battery/reserve` takes `{"pct": 60}`.
|
||||
Same Origin, header and JSON rules as the other writes; `Remote-User` goes on as the requester. The gateway's verdict comes back as
|
||||
`{status, message, read}`: 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon, 502 failed (`read` is what the
|
||||
inverter holds), 400 bad body, 503 gateway unreachable or without battery commands (ADR-013).
|
||||
|
||||
`GET /api/audit?range=7d` (`24h`, `7d` default, `30d`; 400 otherwise, 502 when InfluxDB fails) is the change history, example
|
||||
`src/test/resources/api/audit.json`: the gateway's audit rows of the heat pump (`Dom_Pompa.setpoint_audit`) and the battery
|
||||
(`electricity.deye_settings_audit`), read only, as `entries` newest first: `time`, `source` (`heat_pump`, `battery`), `what` (Polish label;
|
||||
the Deye registers are named in `DeyeSettingLabels`), `oldValue`/`newValue` (whole numbers, null when not recorded), `unit` (`°C`, `%`,
|
||||
`HHMM`, `0/1`), `who` (`Remote-User` of the request; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
|
||||
|
||||
`GET /api/weather` (example `src/test/resources/api/weather.json`) is the forecast of Open-Meteo for the house and the IMGW warnings that name
|
||||
Krosno (TERYT 1862 or the powiat 1807): `today`, `tomorrow`, `dayAfter` (`date`, `weatherCode` WMO, Polish `description`, `tMax`, `tMin`,
|
||||
`precipitationMm`, `sunshineHours`, `pvForecastKwh`), `now.temperature`, `warnings` (`event`, `level` 1-3, `from`, `to`, `text`; the ones that
|
||||
ended are dropped). `pvFactor` is kWh per MJ/m^2: the Deye plus Fronius energy of the past 14 complete days (from InfluxDB, daily maximum of
|
||||
`pv_today_kwh` and `energy_day_wh`) over the forecast radiation of the same days, so no tilt or azimuth is needed; `pvForecastKwh` is the factor
|
||||
times the day's radiation, and both are null with fewer than 5 days that have both values. The forecast is kept 30 min and the warnings 10 min;
|
||||
when a refresh fails the older answer is served with `stale` true. 503 only when there never was a forecast. The sources are public and need no key.
|
||||
|
||||
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||
without the two URLs.
|
||||
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `GATEWAY_URL` | required | the gateway on the Pi, e.g. `http://10.0.0.2:8080` |
|
||||
| `INFLUX_URL` | required | InfluxDB 1.x, e.g. `https://influxdb.example` |
|
||||
| `PORT` | `8080` | listening port |
|
||||
| `GATEWAY_TIMEOUT` | `5` | seconds |
|
||||
| `INFLUX_TIMEOUT` | `10` | seconds |
|
||||
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
|
||||
| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast |
|
||||
| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count |
|
||||
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
|
||||
|
||||
```bash
|
||||
mvn -f backend/pom.xml clean package
|
||||
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
|
||||
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
|
||||
```
|
||||
|
||||
`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. It
|
||||
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
|
||||
rules (ADR-009).
|
||||
|
||||
`/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).
|
||||
@@ -0,0 +1,16 @@
|
||||
# The backend in Kraków: the API only (ADR-010), reached by the frontend on the network home-pump
|
||||
# and published on no host port (ADR-009). Settings in .env next to this file (README).
|
||||
name: home-pump-backend
|
||||
services:
|
||||
backend:
|
||||
image: git.mariuszpacek.pl/mariusz.pacek/home-pump-backend:${VERSION:-latest}
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: Europe/Warsaw
|
||||
networks: [home-pump]
|
||||
|
||||
networks:
|
||||
home-pump:
|
||||
name: home-pump
|
||||
@@ -1,31 +1,81 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.adapters.gateway.GatewayHttpClient;
|
||||
import home.backend.adapters.gateway.GatewayHttpCommands;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import home.backend.adapters.influx.InfluxAuditRepository;
|
||||
import home.backend.adapters.influx.InfluxHistoryRepository;
|
||||
import home.backend.adapters.influx.InfluxPvEnergy;
|
||||
import home.backend.adapters.influx.InfluxSettings;
|
||||
import home.backend.adapters.weather.ImgwClient;
|
||||
import home.backend.adapters.weather.OpenMeteoClient;
|
||||
import home.backend.adapters.weather.WeatherSettings;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.LeaseRenewer;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.application.WeatherService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Wires the adapters and services together; the only place that knows all of them. */
|
||||
final class Backend {
|
||||
|
||||
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
|
||||
/** Warnings are optional, so a slow IMGW must not hold the forecast. */
|
||||
private static final Duration WARNINGS_TIMEOUT = Duration.ofSeconds(3);
|
||||
|
||||
private Backend() {
|
||||
}
|
||||
|
||||
/** Binds the port but does not start serving. */
|
||||
/**
|
||||
* Binds the port but does not start serving.
|
||||
*
|
||||
* @throws IllegalArgumentException when PRESETS_FILE is unusable
|
||||
*/
|
||||
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
|
||||
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
|
||||
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
|
||||
var influxSettings = new InfluxSettings(settings.influxUrl(), settings.influxTimeout());
|
||||
var influx = new InfluxHistoryRepository(influxSettings);
|
||||
var commands = commands(settings);
|
||||
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
|
||||
return new ApiServer(settings.port(),
|
||||
new HeatPumpService(gateway, clock),
|
||||
new EnergyService(gateway, clock),
|
||||
new HealthService(gateway, clock),
|
||||
new HistoryService(influx, clock));
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
|
||||
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, commands, clock),
|
||||
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
|
||||
weather(influxSettings, clock), commands);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,23 @@ public final class Main {
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
Settings settings;
|
||||
ApiServer server;
|
||||
try {
|
||||
settings = Settings.fromEnvironment(System.getenv());
|
||||
server = Backend.assemble(settings, Clock.systemUTC());
|
||||
} catch (IllegalArgumentException e) {
|
||||
System.err.println("home-pump-backend: " + e.getMessage());
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
ApiServer server = Backend.assemble(settings, Clock.systemUTC());
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
if (settings.gatewayToken() == null) {
|
||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||
} else {
|
||||
var renewer = Backend.leaseRenewer(settings);
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(renewer::close));
|
||||
renewer.start();
|
||||
}
|
||||
server.start();
|
||||
System.out.println("home-pump-backend listening on port " + server.port());
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package home.backend;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Twelve-factor settings: everything comes from environment variables. */
|
||||
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout) {
|
||||
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
|
||||
String gatewayToken, Path presetsFile) {
|
||||
|
||||
public static Settings fromEnvironment(Map<String, String> env) {
|
||||
return new Settings(
|
||||
@@ -13,7 +16,15 @@ public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gateway
|
||||
url(env, "INFLUX_URL"),
|
||||
(int) number(env, "PORT", 8080, 0, 65535),
|
||||
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.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null));
|
||||
}
|
||||
|
||||
/** Null when unset or blank. */
|
||||
private static String optional(Map<String, String> env, String name) {
|
||||
String value = env.get(name);
|
||||
return value == null || value.isBlank() ? null : value.strip();
|
||||
}
|
||||
|
||||
private static URI url(Map<String, String> env, String name) {
|
||||
@@ -25,6 +36,12 @@ public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gateway
|
||||
if (uri.getHost() == null || !("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))) {
|
||||
throw new IllegalArgumentException(name + " must be an http(s) URL with a host, got: " + value);
|
||||
}
|
||||
// The clients append their own paths to this base; a path here would be silently dropped or doubled.
|
||||
boolean hasPath = !uri.getRawPath().isEmpty() && !uri.getRawPath().equals("/");
|
||||
if (hasPath || uri.getRawQuery() != null || uri.getRawFragment() != null) {
|
||||
throw new IllegalArgumentException(name + " must be only scheme, host and port, without a path, got: "
|
||||
+ value);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package home.backend.adapters.config;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.domain.Preset;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
/** Reads the presets (F3) from the optional PRESETS_FILE, else the built-in list. */
|
||||
public final class PresetFile {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final TypeReference<List<Preset>> PRESETS = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private PresetFile() {
|
||||
}
|
||||
|
||||
/** @param file null for the built-in defaults; IllegalArgumentException when it is unusable */
|
||||
public static List<Preset> load(Path file) {
|
||||
String source = file == null ? "built-in presets" : "PRESETS_FILE " + file;
|
||||
try (InputStream in = file == null ? defaults() : Files.newInputStream(file)) {
|
||||
List<Preset> presets = JSON.readValue(in, PRESETS);
|
||||
if (new HashSet<>(presets.stream().map(Preset::id).toList()).size() != presets.size()) {
|
||||
throw new IllegalArgumentException("preset ids must be unique");
|
||||
}
|
||||
return presets;
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException(source + " is not usable: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static InputStream defaults() {
|
||||
return PresetFile.class.getResourceAsStream("/presets-default.json");
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import java.util.stream.Collectors;
|
||||
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
|
||||
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) {
|
||||
@@ -61,7 +61,7 @@ public class GatewayHttpClient implements GatewayClient {
|
||||
device.lastSuccess(), device.lastError(),
|
||||
measurements.getOrDefault(device.name(), List.of())))
|
||||
.toList();
|
||||
return new GatewaySnapshot(devices);
|
||||
return new GatewaySnapshot(health.version(), devices);
|
||||
}
|
||||
|
||||
private static List<Measurement> measurements(DeviceReadings device) {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayBattery;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.application.GatewayRefresh;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView.Reserve;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RefreshResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
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;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** Calls the gateway's {@code /commands}, {@code /battery} and {@code /refresh} with the shared token (S6). */
|
||||
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery, GatewayRefresh {
|
||||
|
||||
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
|
||||
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
|
||||
|
||||
/** Restore now: 200 restored, 404 nothing pending, 409 changed meanwhile, 429 too soon, 502 failed. */
|
||||
private static final Set<Integer> RESTORE_VERDICTS = Set.of(200, 404, 409, 429, 502);
|
||||
|
||||
/** Battery change: 200 applied, 422 rejected, 409 stale or changed meanwhile, 429 too soon, 502 failed. */
|
||||
private static final Set<Integer> BATTERY_VERDICTS = Set.of(200, 422, 409, 429, 502);
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record 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()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private final URI endpoint;
|
||||
private final URI renewEndpoint;
|
||||
private final URI restoreEndpoint;
|
||||
private final URI refreshEndpoint;
|
||||
private final URI reserveEndpoint;
|
||||
private final URI scheduleEndpoint;
|
||||
private final String token;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
|
||||
/** @param token null when not configured: every call then fails without touching the network */
|
||||
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
|
||||
this.endpoint = baseUri.resolve("/commands");
|
||||
this.renewEndpoint = baseUri.resolve("/commands/renew");
|
||||
this.restoreEndpoint = baseUri.resolve("/commands/restore");
|
||||
this.refreshEndpoint = baseUri.resolve("/refresh");
|
||||
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
|
||||
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
|
||||
this.token = token;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandResult apply(Command command) throws IOException {
|
||||
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
|
||||
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes, command.heldUntil()));
|
||||
HttpRequest.Builder request = request().header("Content-Type", "application/json")
|
||||
.header("X-Requested-By", command.requestedBy())
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
byte[] body = JSON.writeValueAsBytes(new RestoreRequest(setpoint));
|
||||
var request = request(restoreEndpoint).header("Content-Type", "application/json")
|
||||
.header("X-Requested-By", requestedBy).POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return JSON.readValue(send(request, RESTORE_VERDICTS), RestoreResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
|
||||
}
|
||||
|
||||
@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 {
|
||||
return request(endpoint);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request(URI uri) throws IOException {
|
||||
if (token == null) {
|
||||
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
|
||||
}
|
||||
return HttpRequest.newBuilder(uri).timeout(timeout).header("Authorization", "Bearer " + token);
|
||||
}
|
||||
|
||||
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
|
||||
try {
|
||||
var built = request.build();
|
||||
var response = http.send(built, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (!accepted.contains(response.statusCode())) {
|
||||
throw new IOException("gateway " + built.uri().getPath() + " answered " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted while calling the gateway", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,42 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.GatewayRefresh;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.application.WeatherService;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** The phone-facing JSON API under {@code /api/} and the static web UI. */
|
||||
@@ -23,20 +44,112 @@ public class ApiServer {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
// 45.5 or "45" must not silently become 45 in a request that writes to a heat pump.
|
||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
|
||||
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
|
||||
|
||||
private record ReserveRequest(Integer pct) {
|
||||
}
|
||||
|
||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
||||
}
|
||||
|
||||
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
|
||||
private record PresetRequest(String until, Map<String, Integer> values) {
|
||||
|
||||
PresetRequest {
|
||||
if (values == null) {
|
||||
values = Map.of();
|
||||
} else if (values.containsValue(null)) {
|
||||
throw new IllegalArgumentException("a value must be a number");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final String UNREACHABLE = "gateway unreachable";
|
||||
private static final String UNAVAILABLE = "battery changes are not available: gateway unreachable or not enabled";
|
||||
private static final String WRITE_MARKER = "home-pump";
|
||||
private static final String PRESETS_PATH = "/api/presets/";
|
||||
private static final String SETPOINTS_PATH = "/api/setpoints/";
|
||||
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
|
||||
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
|
||||
private static final String USER_HEADER = "Remote-User";
|
||||
private static final String DEFAULT_USER = "phone";
|
||||
private static final Pattern USER_NAME = Pattern.compile("[\\w.@+-]{1,64}");
|
||||
|
||||
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
|
||||
|
||||
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
|
||||
private static final int MAX_BODY_BYTES = 4096;
|
||||
private static final int STOP_GRACE_SECONDS = 5;
|
||||
|
||||
private final HttpServer server;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/** Binds the port now; 0 picks a free one. */
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, HealthService health,
|
||||
HistoryService history) throws IOException {
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
|
||||
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather,
|
||||
GatewayRefresh refresh) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
|
||||
server.createContext("/", new StaticFiles());
|
||||
server.setExecutor(executor);
|
||||
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
|
||||
context("/", exchange -> {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
});
|
||||
api("/api/heat-pump", query -> heatPump.current());
|
||||
api("/api/energy", query -> energy.current());
|
||||
api("/api/energy/details", query -> energyDetails.current());
|
||||
api("/api/battery", query -> battery.current());
|
||||
route("/api/battery/schedule", exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
|
||||
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
|
||||
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());
|
||||
server.createContext("/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(
|
||||
"GET", () -> getSetpoints(exchange, setpoints),
|
||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||
context(SETPOINTS_PATH, exchange -> {
|
||||
var restore = RESTORE_PATH.matcher(exchange.getRequestURI().getPath());
|
||||
if (restore.matches()) {
|
||||
handle(exchange, Map.of("POST", () -> guarded(exchange,
|
||||
body -> postRestore(exchange, restore.group(1), setpoints))));
|
||||
} else {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
});
|
||||
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
|
||||
}
|
||||
|
||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||
private void route(String path, HttpHandler handler) {
|
||||
context(path, exchange -> {
|
||||
if (path.equals(exchange.getRequestURI().getPath())) {
|
||||
handler.handle(exchange);
|
||||
} else {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void context(String path, HttpHandler handler) {
|
||||
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
@@ -44,7 +157,8 @@ public class ApiServer {
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
server.stop(0);
|
||||
server.stop(STOP_GRACE_SECONDS);
|
||||
executor.shutdown();
|
||||
}
|
||||
|
||||
public int port() {
|
||||
@@ -57,17 +171,179 @@ public class ApiServer {
|
||||
|
||||
/** Gateway trouble on a live-state endpoint is the gateway being unreachable: 503. */
|
||||
private void api(String path, Gateway gateway) {
|
||||
server.createContext(path, exchange -> respond(exchange, () -> {
|
||||
route(path, exchange -> handle(exchange, Map.of("GET", () -> {
|
||||
try {
|
||||
send(exchange, 200, gateway.get(Map.of()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error("gateway unreachable"));
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}));
|
||||
})));
|
||||
}
|
||||
|
||||
/**
|
||||
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
|
||||
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
|
||||
*/
|
||||
private static void guarded(HttpExchange exchange, Write write) throws IOException {
|
||||
var headers = exchange.getRequestHeaders();
|
||||
String origin = headers.getFirst("Origin");
|
||||
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
|
||||
send(exchange, 403, error("cross-site request"));
|
||||
} else if (!WRITE_MARKER.equals(headers.getFirst("X-Requested-With"))) {
|
||||
send(exchange, 403, error("missing X-Requested-With: " + WRITE_MARKER));
|
||||
} else if (!isJson(headers.getFirst("Content-Type"))) {
|
||||
send(exchange, 415, error("expected Content-Type: application/json"));
|
||||
} else {
|
||||
byte[] body = exchange.getRequestBody().readNBytes(MAX_BODY_BYTES + 1);
|
||||
if (body.length > MAX_BODY_BYTES) {
|
||||
send(exchange, 413, error("request body over " + MAX_BODY_BYTES + " bytes"));
|
||||
} else {
|
||||
write.run(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isJson(String contentType) {
|
||||
return contentType != null && contentType.split(";")[0].trim().equalsIgnoreCase("application/json");
|
||||
}
|
||||
|
||||
private static void getSetpoints(HttpExchange exchange, SetpointService setpoints) throws IOException {
|
||||
try {
|
||||
send(exchange, 200, setpoints.current());
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postSetpoint(HttpExchange exchange, byte[] body, SetpointService setpoints)
|
||||
throws IOException {
|
||||
try {
|
||||
var request = JSON.readValue(body, SetpointRequest.class);
|
||||
if (request == null || request.setpoint() == null || request.value() == null) {
|
||||
throw new IllegalArgumentException("setpoint and value are required");
|
||||
}
|
||||
var result = setpoints.apply(request.setpoint(), request.value(), request.hours(), user(exchange));
|
||||
send(exchange, httpStatus(result.status()), result);
|
||||
} catch (JsonProcessingException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"setpoint\", \"value\", \"hours\"?}"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
try {
|
||||
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
|
||||
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
|
||||
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
|
||||
if (result.isPresent()) {
|
||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||
} else {
|
||||
send(exchange, 404, error("no such preset"));
|
||||
}
|
||||
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
|
||||
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
|
||||
+ "for setpoints of the preset"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static int httpStatus(Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
case REJECTED -> 422;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static int batteryHttpStatus(BatteryResult.Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
case REJECTED -> 422;
|
||||
case STALE -> 409;
|
||||
case TOO_SOON -> 429;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
|
||||
return switch (outcome) {
|
||||
case RESTORED -> 200;
|
||||
case NOT_PENDING -> 404;
|
||||
case CONFLICT -> 409;
|
||||
case TOO_SOON -> 429;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static int presetHttpStatus(PresetResult.Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
case REJECTED -> 422;
|
||||
case FAILED, PARTIAL -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static String user(HttpExchange exchange) {
|
||||
String name = exchange.getRequestHeaders().getFirst(USER_HEADER);
|
||||
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
|
||||
}
|
||||
|
||||
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
||||
respond(exchange, () -> {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
|
||||
try {
|
||||
send(exchange, 200, history.history(query.get("series"), query.get("range")));
|
||||
@@ -76,20 +352,58 @@ public class ApiServer {
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("history unavailable"));
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleAudit(HttpExchange exchange, AuditService audit) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
|
||||
try {
|
||||
send(exchange, 200, audit.audit(query.get("range")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("audit unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleStats(HttpExchange exchange, StatsService stats) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
try {
|
||||
send(exchange, 200, stats.stats(parseQuery(exchange.getRequestURI().getRawQuery()).get("range")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("stats unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private interface Action {
|
||||
void run() throws IOException;
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, Action action) throws IOException {
|
||||
private interface Write {
|
||||
void run(byte[] body) throws IOException;
|
||||
}
|
||||
|
||||
private static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
|
||||
try (exchange) {
|
||||
if (!exchange.getRequestMethod().equals("GET")) {
|
||||
Action action = byMethod.get(exchange.getRequestMethod());
|
||||
if (action == null) {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
action.run();
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException e) {
|
||||
// A gateway payload the client could not map (duplicate device, missing status): answer, do not drop.
|
||||
System.err.println("home-pump-backend: " + exchange.getRequestURI().getPath() + ": " + e);
|
||||
if (exchange.getResponseCode() == -1) {
|
||||
send(exchange, 502, error("bad gateway response"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.sun.net.httpserver.Filter;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** Sets the browser hardening headers on every response, whatever handler answers. */
|
||||
final class SecurityHeaders extends Filter {
|
||||
|
||||
private static final String POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; "
|
||||
+ "img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; "
|
||||
+ "form-action 'self'; frame-ancestors 'none'";
|
||||
|
||||
@Override
|
||||
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
|
||||
var headers = exchange.getResponseHeaders();
|
||||
headers.set("Content-Security-Policy", POLICY);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("Referrer-Policy", "no-referrer");
|
||||
chain.doFilter(exchange);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "security headers";
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
/** Serves the web UI from the classpath folder {@code web/}; only files of known types. */
|
||||
final class StaticFiles implements HttpHandler {
|
||||
|
||||
private static final Map<String, String> CONTENT_TYPES = Map.of(
|
||||
"html", "text/html; charset=utf-8",
|
||||
"css", "text/css; charset=utf-8",
|
||||
"js", "text/javascript; charset=utf-8",
|
||||
"svg", "image/svg+xml",
|
||||
"png", "image/png",
|
||||
"ico", "image/x-icon");
|
||||
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
try (exchange) {
|
||||
if (!exchange.getRequestMethod().equals("GET")) {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
String resource = path.equals("/") ? "index.html" : path.substring(1);
|
||||
String type = contentType(resource);
|
||||
try (InputStream in = type == null || !isSafe(resource) ? null : open(resource)) {
|
||||
if (in == null) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
byte[] body = in.readAllBytes();
|
||||
exchange.getResponseHeaders().set("Content-Type", type);
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-cache");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
try (OutputStream out = exchange.getResponseBody()) {
|
||||
out.write(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static InputStream open(String resource) {
|
||||
return StaticFiles.class.getClassLoader().getResourceAsStream("web/" + resource);
|
||||
}
|
||||
|
||||
/** The path is already URL-decoded, so this also catches %2e%2e. */
|
||||
private static boolean isSafe(String resource) {
|
||||
return !resource.contains("\\") && !resource.contains("\0") && !resource.contains("//")
|
||||
&& Arrays.stream(resource.split("/")).noneMatch(s -> s.equals("..") || s.equals("."));
|
||||
}
|
||||
|
||||
private static String contentType(String resource) {
|
||||
int dot = resource.lastIndexOf('.');
|
||||
return dot < 0 ? null : CONTENT_TYPES.get(resource.substring(dot + 1));
|
||||
}
|
||||
}
|
||||
@@ -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,31 +1,27 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import home.backend.application.HeatPumpSamples;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
|
||||
public final class InfluxHistoryRepository implements HistoryRepository {
|
||||
public final class InfluxHistoryRepository implements HistoryRepository, HeatPumpSamples {
|
||||
|
||||
private final InfluxSettings settings;
|
||||
private final HttpClient http;
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxHistoryRepository(InfluxSettings settings) {
|
||||
this.settings = settings;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -35,44 +31,39 @@ public final class InfluxHistoryRepository implements HistoryRepository {
|
||||
}
|
||||
long stepMinutes = stepMinutes(from, to, maxPoints);
|
||||
Map<Long, Double> summed = new TreeMap<>();
|
||||
for (Source source : SeriesSources.of(series)) {
|
||||
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
|
||||
// A part without data is absent, not zero, so a bucket is kept if any part has it.
|
||||
InfluxResponse.parse(fetch(source.database(), query)).forEach((t, v) -> summed.merge(t, v, Double::sum));
|
||||
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
var parts = SeriesSources.of(series).stream()
|
||||
.map(source -> executor.submit(() -> part(source, from, to, stepMinutes)))
|
||||
.toList();
|
||||
// Summed in source order, so the result does not depend on which answer comes first.
|
||||
for (var part : parts) {
|
||||
// A part without data is absent, not zero, so a bucket is kept if any part has it.
|
||||
part.get().forEach((t, v) -> summed.merge(t, v, Double::sum));
|
||||
}
|
||||
} catch (ExecutionException e) {
|
||||
throw e.getCause() instanceof IOException io ? io : new IOException(e.getCause());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
}
|
||||
return summed.entrySet().stream()
|
||||
.map(e -> new Point(Instant.ofEpochMilli(e.getKey()), e.getValue()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HeatPumpSample> samples(Instant from, Instant to) throws IOException {
|
||||
String query = InfluxSamples.query(from.toEpochMilli(), to.toEpochMilli());
|
||||
return InfluxSamples.parse(influx.query(SeriesSources.LEGACY, query));
|
||||
}
|
||||
|
||||
private Map<Long, Double> part(Source source, Instant from, Instant to, long stepMinutes) throws IOException {
|
||||
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
|
||||
return InfluxResponse.parse(influx.query(source.database(), query));
|
||||
}
|
||||
|
||||
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
|
||||
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
|
||||
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
|
||||
}
|
||||
|
||||
private String fetch(String database, String query) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
|
||||
.timeout(settings.requestTimeout())
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private URI queryUri(String database, String query) {
|
||||
String base = settings.baseUri().toString().replaceAll("/+$", "");
|
||||
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import home.backend.application.PvEnergyRepository;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** Daily PV energy: the Deye's and the Fronius's own day counters, each read as the day's maximum. */
|
||||
public final class InfluxPvEnergy implements PvEnergyRepository {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||
private static final String DATABASE = "electricity";
|
||||
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxPvEnergy(InfluxSettings settings) {
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException {
|
||||
Map<LocalDate, Double> deye = dailyMax("deye", "pv_today_kwh", from, to);
|
||||
Map<LocalDate, Double> fronius = dailyMax("fronius", "energy_day_wh", from, to);
|
||||
Map<LocalDate, Double> total = new TreeMap<>();
|
||||
deye.forEach((day, kwh) -> {
|
||||
Double wh = fronius.get(day);
|
||||
if (wh != null) {
|
||||
total.put(day, kwh + wh / 1000);
|
||||
}
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
private Map<LocalDate, Double> dailyMax(String measurement, String field, LocalDate from, LocalDate to)
|
||||
throws IOException {
|
||||
String query = "SELECT max(\"" + field + "\") FROM \"" + measurement + "\" WHERE time >= "
|
||||
+ millis(from) + "ms AND time < " + millis(to) + "ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')";
|
||||
Map<LocalDate, Double> days = new TreeMap<>();
|
||||
InfluxResponse.parse(influx.query(DATABASE, query)).forEach((start, max) ->
|
||||
days.put(Instant.ofEpochMilli(start).atZone(ZONE).toLocalDate(), max));
|
||||
return days;
|
||||
}
|
||||
|
||||
private static long millis(LocalDate day) {
|
||||
return day.atStartOfDay(ZONE).toInstant().toEpochMilli();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** The raw {@code heat_pump} rows of an InfluxDB 1.x query. */
|
||||
final class InfluxSamples {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private InfluxSamples() {
|
||||
}
|
||||
|
||||
static String query(long fromMs, long toMs) {
|
||||
return "SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
|
||||
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
|
||||
}
|
||||
|
||||
/** The rows of {@link #query}, as returned (ascending); empty when there is no data. */
|
||||
static List<HeatPumpSample> parse(String body) throws IOException {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = JSON.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
}
|
||||
failOnError(root);
|
||||
List<HeatPumpSample> samples = new ArrayList<>();
|
||||
for (JsonNode result : root.path("results")) {
|
||||
failOnError(result);
|
||||
for (JsonNode series : result.path("series")) {
|
||||
for (JsonNode row : series.path("values")) {
|
||||
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
|
||||
number(row, 1), number(row, 2), number(row, 3), number(row, 4),
|
||||
number(row, 5), number(row, 6)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
private static Double number(JsonNode row, int column) {
|
||||
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
|
||||
}
|
||||
|
||||
private static void failOnError(JsonNode node) throws IOException {
|
||||
if (node.hasNonNull("error")) {
|
||||
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import java.util.Map;
|
||||
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
|
||||
final class SeriesSources {
|
||||
|
||||
private static final String LEGACY = "Dom_Pompa";
|
||||
static final String LEGACY = "Dom_Pompa";
|
||||
private static final String ELECTRICITY = "electricity";
|
||||
|
||||
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
|
||||
/** GET of a public JSON document, with a timeout on connecting and on the whole request. */
|
||||
final class HttpJson {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private final HttpClient http;
|
||||
private final Duration timeout;
|
||||
|
||||
HttpJson(Duration timeout) {
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
JsonNode get(URI uri) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(uri).timeout(timeout).GET().build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException(uri.getHost() + " answered HTTP " + response.statusCode());
|
||||
}
|
||||
return JSON.readTree(response.body());
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException(uri.getHost() + " returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("request to " + uri.getHost() + " interrupted", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import home.backend.application.WarningSource;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* IMGW's public list of meteorological warnings, kept to those naming one of our TERYT areas. A warning
|
||||
* that is malformed (no degree or times) is left out rather than failing the others.
|
||||
*/
|
||||
public final class ImgwClient implements WarningSource {
|
||||
|
||||
private static final ZoneId POLAND = ZoneId.of("Europe/Warsaw");
|
||||
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final HttpJson http;
|
||||
private final URI uri;
|
||||
private final List<String> teryt;
|
||||
|
||||
public ImgwClient(URI baseUri, WeatherSettings place, Duration timeout) {
|
||||
this.http = new HttpJson(timeout);
|
||||
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/api/data/warningsmeteo");
|
||||
this.teryt = place.teryt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Warning> warnings() throws IOException {
|
||||
JsonNode root = http.get(uri);
|
||||
if (!root.isArray()) {
|
||||
throw new IOException("IMGW returned no list of warnings");
|
||||
}
|
||||
List<Warning> ours = new ArrayList<>();
|
||||
for (JsonNode item : root) {
|
||||
if (covers(item.path("teryt"))) {
|
||||
warning(item).ifPresent(ours::add);
|
||||
}
|
||||
}
|
||||
return ours;
|
||||
}
|
||||
|
||||
private boolean covers(JsonNode codes) {
|
||||
for (JsonNode code : codes) {
|
||||
if (teryt.contains(code.asText())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Optional<Warning> warning(JsonNode item) {
|
||||
try {
|
||||
return Optional.of(new Warning(item.path("nazwa_zdarzenia").asText(),
|
||||
Integer.parseInt(item.path("stopien").asText()),
|
||||
time(item.path("obowiazuje_od")), time(item.path("obowiazuje_do")),
|
||||
item.path("tresc").asText()));
|
||||
} catch (NumberFormatException | DateTimeException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static OffsetDateTime time(JsonNode text) {
|
||||
return LocalDateTime.parse(text.asText(), TIME).atZone(POLAND).toOffsetDateTime();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import home.backend.application.ForecastSource;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.Forecast.DailyForecast;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 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,15 +1,16 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.EnergyView.Today;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
|
||||
@@ -25,23 +26,23 @@ public class EnergyService {
|
||||
|
||||
public EnergyView current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
Optional<Measurement> deye = SnapshotFields.find(snapshot, "deye", "deye");
|
||||
Optional<Measurement> fronius = SnapshotFields.find(snapshot, "fronius", "fronius");
|
||||
Map<String, Double> d = deye.map(Measurement::fields).orElse(Map.of());
|
||||
Map<String, Double> f = fronius.map(Measurement::fields).orElse(Map.of());
|
||||
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
|
||||
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
|
||||
Map<String, Double> d = deye.fields();
|
||||
Map<String, Double> f = fronius.fields();
|
||||
|
||||
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
|
||||
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
|
||||
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), d.get("load_today_kwh"));
|
||||
return new EnergyView(time(deye, fronius),
|
||||
List<Source> sources = List.of(deye.source(), fronius.source());
|
||||
return new EnergyView(oldest(sources), sources,
|
||||
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
|
||||
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
|
||||
d.get("load_power_w"), today);
|
||||
}
|
||||
|
||||
private Instant time(Optional<Measurement> deye, Optional<Measurement> fronius) {
|
||||
return Stream.of(deye, fronius).flatMap(Optional::stream).map(Measurement::time)
|
||||
.max(Instant::compareTo).orElseGet(clock::instant);
|
||||
private static Instant oldest(List<Source> sources) {
|
||||
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
/** Sum of the values that exist; null when none does. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** The only way the backend writes anything: through the gateway, which checks every command. */
|
||||
public interface GatewayCommands {
|
||||
|
||||
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
|
||||
CommandResult apply(Command command) throws IOException;
|
||||
|
||||
/** Ends the pending change of a setpoint now; a verdict like {@link #apply}. */
|
||||
RestoreResult restore(String setpoint, String requestedBy) throws IOException;
|
||||
|
||||
SetpointState setpoints() throws IOException;
|
||||
|
||||
/** Keeps the gateway's live restores alive; returns how many it extended. */
|
||||
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;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HealthView.DeviceHealth;
|
||||
@@ -13,19 +14,21 @@ public class HealthService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
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.clock = clock;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public HealthView current() {
|
||||
try {
|
||||
List<DeviceHealth> devices = gateway.snapshot().devices().stream()
|
||||
.map(HealthService::health).toList();
|
||||
return new HealthView(clock.instant(), true, devices);
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
List<DeviceHealth> devices = snapshot.devices().stream().map(HealthService::health).toList();
|
||||
return new HealthView(clock.instant(), true, devices, version, snapshot.version());
|
||||
} 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;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.HeatPumpView;
|
||||
import home.backend.domain.HeatPumpView.Pumps;
|
||||
@@ -12,6 +13,7 @@ import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static home.backend.application.SnapshotFields.on;
|
||||
import static home.backend.application.SnapshotFields.whole;
|
||||
@@ -31,15 +33,18 @@ public class HeatPumpService {
|
||||
}
|
||||
|
||||
public HeatPumpView current() throws IOException {
|
||||
return SnapshotFields.find(gateway.snapshot(), "heat-pump", "heat_pump")
|
||||
.filter(m -> DATABASE.equals(m.database()))
|
||||
.map(m -> view(m.time(), m.fields()))
|
||||
.orElseGet(() -> view(clock.instant(), Map.of()));
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
Optional<Measurement> reading = SnapshotFields.find(snapshot, "heat-pump", "heat_pump")
|
||||
.filter(m -> DATABASE.equals(m.database()));
|
||||
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, "heat-pump"), reading, clock.instant());
|
||||
return view(reading.map(Measurement::time).orElse(null), stale,
|
||||
reading.map(Measurement::fields).orElse(Map.of()));
|
||||
}
|
||||
|
||||
private static HeatPumpView view(Instant time, Map<String, Double> f) {
|
||||
return new HeatPumpView(time, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
|
||||
on(f, "alarm"),
|
||||
private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) {
|
||||
Integer alarmCode = whole(f, "alarm_code");
|
||||
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
|
||||
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
|
||||
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
|
||||
on(f, "output_ground"), on(f, "output_cwu")),
|
||||
new Temperatures(tenths(f, "outdoor_temp"), tenths(f, "indoor_temp"),
|
||||
@@ -52,6 +57,23 @@ public class HeatPumpService {
|
||||
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort")));
|
||||
}
|
||||
|
||||
/** Codes of register 4011 as the vendor app names them; unverified on our device. */
|
||||
private static String alarmText(Integer code) {
|
||||
if (code == null || code == 0) {
|
||||
return null;
|
||||
}
|
||||
return switch (code) {
|
||||
case 2 -> "Usterka czujnika temperatury zewnętrznej";
|
||||
case 3 -> "Usterka czujnika temperatury CO";
|
||||
case 4 -> "Usterka czujnika temperatury wewnętrznej";
|
||||
case 5 -> "Usterka czujnika temperatury CWU";
|
||||
case 14 -> "Usterka czujnika temperatury wyjścia pompy";
|
||||
case 15 -> "Usterka czujnika temperatury dolnego źródła";
|
||||
case 1003 -> "Zadziałał presostat (ciśnienie)";
|
||||
default -> "Kod usterki " + code;
|
||||
};
|
||||
}
|
||||
|
||||
/** The controller resolves 0.1 °C; the gateway's 212 * 0.1 is 21.200000000000003. */
|
||||
private static Double tenths(Map<String, Double> fields, String name) {
|
||||
Double value = fields.get(name);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.PresetResult.ChangeResult;
|
||||
import home.backend.domain.PresetResult.ChangeStatus;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import home.backend.domain.SetpointsView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
||||
public class SetpointService {
|
||||
|
||||
private final GatewayCommands gateway;
|
||||
private final List<Preset> presets;
|
||||
|
||||
public SetpointService(GatewayCommands gateway, List<Preset> presets) {
|
||||
this.gateway = gateway;
|
||||
this.presets = List.copyOf(presets);
|
||||
}
|
||||
|
||||
public SetpointsView current() throws IOException {
|
||||
SetpointState state = gateway.setpoints();
|
||||
return new SetpointsView(state.setpoints(), state.restores(), presets);
|
||||
}
|
||||
|
||||
/** @param hours how long to keep the value, null until changed back */
|
||||
public CommandResult apply(String setpoint, int value, Double hours, String user) throws IOException {
|
||||
return gateway.apply(new Command(setpoint, value, duration(hours), user));
|
||||
}
|
||||
|
||||
/** Puts the value from before the pending change back now, instead of waiting for its end. */
|
||||
public RestoreResult restore(String setpoint, String user) throws IOException {
|
||||
return gateway.restore(setpoint, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
|
||||
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
|
||||
*
|
||||
* @param until the end the user chose; required by a held preset, not allowed for any other
|
||||
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
|
||||
* preset's value
|
||||
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
|
||||
* for a setpoint the preset does not change or outside the gateway's limits
|
||||
*/
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
|
||||
throws IOException {
|
||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||
if (preset.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
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();
|
||||
requireWithinLimits(values, limits);
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
|
||||
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
|
||||
return Optional.of(PresetResult.of(checked));
|
||||
}
|
||||
return Optional.of(PresetResult.of(run(changes, until, user)));
|
||||
}
|
||||
|
||||
private static List<Change> withValues(List<Change> changes, Map<String, Integer> values) {
|
||||
Set<String> own = changes.stream().map(Change::setpoint).collect(Collectors.toSet());
|
||||
if (!own.containsAll(values.keySet())) {
|
||||
throw new IllegalArgumentException("values may only be for the setpoints the preset changes: " + own);
|
||||
}
|
||||
return changes.stream().map(c -> new Change(c.setpoint(), values.getOrDefault(c.setpoint(), c.value()),
|
||||
c.hours())).toList();
|
||||
}
|
||||
|
||||
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
|
||||
for (Setpoint limit : limits) {
|
||||
Integer value = values.get(limit.name());
|
||||
if (value != null && !limit.allows(value)) {
|
||||
throw new IllegalArgumentException(limit.name() + " must be " + limit.min() + "–" + limit.max()
|
||||
+ " in steps of " + limit.step());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
|
||||
List<ChangeResult> results = new ArrayList<>();
|
||||
for (Change change : changes) {
|
||||
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
|
||||
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, until, user));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private ChangeResult attempt(Change change, Instant until, String user) {
|
||||
try {
|
||||
var command = until == null ? new Command(change.setpoint(), change.value(), duration(change.hours()), user)
|
||||
: new Command(change.setpoint(), change.value(), null, until, user);
|
||||
return ChangeResult.of(gateway.apply(command));
|
||||
} catch (IOException e) {
|
||||
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
|
||||
}
|
||||
}
|
||||
|
||||
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
|
||||
private static ChangeResult check(Change change, boolean held, List<Setpoint> limits) {
|
||||
String problem = problem(change, held, limits);
|
||||
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
|
||||
}
|
||||
|
||||
private static String problem(Change change, boolean held, List<Setpoint> limits) {
|
||||
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
|
||||
if (known.isEmpty()) {
|
||||
return "preset sets " + change.setpoint() + ", which the gateway does not know";
|
||||
}
|
||||
if (!known.get().allows(change.value())) {
|
||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
|
||||
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
|
||||
}
|
||||
// The gateway refuses a held raise too; asking first keeps a preset from stopping half way.
|
||||
if (held && known.get().value() != null && change.value() >= known.get().value()) {
|
||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", which is not below the current "
|
||||
+ known.get().value();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Duration duration(Double hours) {
|
||||
if (hours == null) {
|
||||
return null;
|
||||
}
|
||||
long minutes = Math.round(hours * 60);
|
||||
if (minutes < 1 || minutes > Command.MAX_HOURS * 60L) {
|
||||
throw new IllegalArgumentException("hours must be from one minute to " + Command.MAX_HOURS + " hours");
|
||||
}
|
||||
return Duration.ofMinutes(minutes);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -14,6 +17,39 @@ final class SnapshotFields {
|
||||
private SnapshotFields() {
|
||||
}
|
||||
|
||||
/** Twice the 300 s the UI treats as one polling cycle; older than that a reading is not live. */
|
||||
private static final Duration MAX_AGE = Duration.ofSeconds(600);
|
||||
|
||||
static Optional<DeviceSnapshot> device(GatewaySnapshot snapshot, String name) {
|
||||
return snapshot.devices().stream().filter(d -> d.name().equals(name)).findFirst();
|
||||
}
|
||||
|
||||
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
|
||||
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
|
||||
return stale(device, reading, now, MAX_AGE);
|
||||
}
|
||||
|
||||
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now,
|
||||
Duration maxAge) {
|
||||
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|
||||
|| Duration.between(reading.get().time(), now).compareTo(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) {
|
||||
return snapshot.devices().stream()
|
||||
.filter(d -> d.name().equals(device))
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Set a setpoint. With a duration the gateway restores the old value afterwards; with
|
||||
* {@code heldUntil} it holds a lowering value until then without renewals (ADR-012); else it stays
|
||||
* while the backend renews the lease (ADR-011).
|
||||
*/
|
||||
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
|
||||
|
||||
public Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||
this(setpoint, value, duration, null, requestedBy);
|
||||
}
|
||||
|
||||
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
|
||||
public static final int MAX_HOURS = 72;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** The gateway's verdict on a command, passed on to the phone unchanged. */
|
||||
public record CommandResult(Status status, String message, Instant restoreAt) {
|
||||
|
||||
/** REJECTED: refused before any write. FAILED: the write or its read-back failed. */
|
||||
public enum Status { APPLIED, REJECTED, FAILED }
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
|
||||
* − charge. Null when the source device is not read.
|
||||
* − charge. Values that come only from a stale source are null. {@code time} is the oldest source
|
||||
* time (null when none was read); {@code sources} lists Deye and Fronius with their own time and freshness.
|
||||
*/
|
||||
public record EnergyView(Instant time, Double pvPowerW, Double gridPowerW, Double batteryPowerW,
|
||||
Double batterySocPct, Double loadPowerW, Today today) {
|
||||
public record EnergyView(Instant time, List<Source> sources, Double pvPowerW, Double gridPowerW,
|
||||
Double batteryPowerW, Double batterySocPct, Double loadPowerW, Today today) {
|
||||
|
||||
/** One inverter; time is null and stale true when the gateway has no reading of it. */
|
||||
public record Source(String name, Instant time, boolean stale) {
|
||||
}
|
||||
|
||||
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
|
||||
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) {
|
||||
|
||||
@@ -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.Map;
|
||||
|
||||
/** The gateway's latest state of every device, as the backend sees it. */
|
||||
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
|
||||
/** The gateway's latest state of every device, as the backend sees it.
|
||||
* 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,
|
||||
List<Measurement> measurements) {
|
||||
|
||||
@@ -3,8 +3,10 @@ package home.backend.domain;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Health of the chain phone → backend → gateway → devices (F5). */
|
||||
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
|
||||
/** Health of the chain phone → backend → gateway → devices (F5), with the deployed versions.
|
||||
* 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) {
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -2,9 +2,13 @@ package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** What the heat pump does now (F1). Values are null when the gateway has not read them yet. */
|
||||
public record HeatPumpView(Instant time, State state, Integer compressorPercent, boolean heaterOn,
|
||||
boolean alarm, Pumps pumps, Temperatures temperatures, Targets targets, Setpoints setpoints) {
|
||||
/**
|
||||
* What the heat pump does now (F1). Without a reading from the gateway {@code time} and the values are
|
||||
* null; {@code stale} is true when the device is down or the reading is older than 2 × 300 s.
|
||||
*/
|
||||
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent, boolean heaterOn,
|
||||
boolean alarm, Integer alarmCode, String alarmText, Pumps pumps, Temperatures temperatures,
|
||||
Targets targets, Setpoints setpoints) {
|
||||
|
||||
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
|
||||
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A named one-tap bundle of setpoint changes (F3), e.g. boost or away. {@code until} is null, or
|
||||
* {@link #CHOSEN}: the user picks the end on a calendar and the gateway holds the changes until
|
||||
* then without renewals (ADR-012).
|
||||
*/
|
||||
public record Preset(String id, String name, @JsonInclude(JsonInclude.Include.NON_NULL) String until,
|
||||
List<Change> changes) {
|
||||
|
||||
public static final String CHOSEN = "chosen";
|
||||
|
||||
/** {@code hours} null means "until changed back". */
|
||||
public record Change(String setpoint, int value, Double hours) {
|
||||
|
||||
public Change {
|
||||
if (setpoint == null || setpoint.isBlank()) {
|
||||
throw new IllegalArgumentException("a preset change needs a setpoint");
|
||||
}
|
||||
if (hours != null && (hours <= 0 || hours > Command.MAX_HOURS)) {
|
||||
throw new IllegalArgumentException("preset hours must be above 0 and at most " + Command.MAX_HOURS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isHeld() {
|
||||
return CHOSEN.equals(until);
|
||||
}
|
||||
|
||||
public Preset {
|
||||
if (id == null || id.isBlank() || name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("a preset needs an id and a name");
|
||||
}
|
||||
if (changes == null || changes.isEmpty()) {
|
||||
throw new IllegalArgumentException("preset " + id + " has no changes");
|
||||
}
|
||||
if (until != null && !until.equals(CHOSEN)) {
|
||||
throw new IllegalArgumentException("preset " + id + ": until can only be \"" + CHOSEN + "\"");
|
||||
}
|
||||
if (CHOSEN.equals(until) && changes.stream().anyMatch(change -> change.hours() != null)) {
|
||||
throw new IllegalArgumentException("preset " + id + " is held until a chosen time, so no hours");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What happened to each change of a preset, one entry per change in preset order. Changes run in
|
||||
* order and stop at the first that is not applied; the rest are NOT_ATTEMPTED.
|
||||
*
|
||||
* <p>{@code status}: APPLIED when every change was; PARTIAL when some were and some were not;
|
||||
* otherwise FAILED if any change failed or went unanswered (the pump may have changed), else
|
||||
* REJECTED (nothing was written).
|
||||
*/
|
||||
public record PresetResult(Status status, List<ChangeResult> results) {
|
||||
|
||||
public enum Status { APPLIED, REJECTED, FAILED, PARTIAL }
|
||||
|
||||
/** UNKNOWN: the gateway did not answer, so the change may or may not have reached the pump. */
|
||||
public enum ChangeStatus { APPLIED, REJECTED, FAILED, NOT_ATTEMPTED, UNKNOWN }
|
||||
|
||||
public record ChangeResult(ChangeStatus status, String message, Instant restoreAt) {
|
||||
|
||||
public static ChangeResult of(CommandResult verdict) {
|
||||
return new ChangeResult(ChangeStatus.valueOf(verdict.status().name()), verdict.message(),
|
||||
verdict.restoreAt());
|
||||
}
|
||||
|
||||
public static ChangeResult notAttempted() {
|
||||
return new ChangeResult(ChangeStatus.NOT_ATTEMPTED, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public static PresetResult of(List<ChangeResult> results) {
|
||||
boolean applied = results.stream().anyMatch(r -> r.status() == ChangeStatus.APPLIED);
|
||||
boolean all = results.stream().allMatch(r -> r.status() == ChangeStatus.APPLIED);
|
||||
boolean maybeWritten = results.stream()
|
||||
.anyMatch(r -> r.status() == ChangeStatus.FAILED || r.status() == ChangeStatus.UNKNOWN);
|
||||
Status status = all ? Status.APPLIED
|
||||
: applied ? Status.PARTIAL
|
||||
: maybeWritten ? Status.FAILED
|
||||
: Status.REJECTED;
|
||||
return new PresetResult(status, results);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The PV output per unit of radiation, found from past days instead of the panels' tilt and azimuth:
|
||||
* measured kWh over forecast MJ/m², summed over the days that have both.
|
||||
*/
|
||||
public final class PvCalibration {
|
||||
|
||||
static final int MIN_DAYS = 5;
|
||||
|
||||
private PvCalibration() {
|
||||
}
|
||||
|
||||
/** Null with fewer than {@value MIN_DAYS} days having both values or no radiation at all. */
|
||||
public static Double factor(Map<LocalDate, Double> measuredKwh, Map<LocalDate, Double> radiationMj) {
|
||||
double kwh = 0;
|
||||
double mj = 0;
|
||||
int days = 0;
|
||||
for (var day : measuredKwh.entrySet()) {
|
||||
Double radiation = radiationMj.get(day.getKey());
|
||||
if (radiation != null) {
|
||||
kwh += day.getValue();
|
||||
mj += radiation;
|
||||
days++;
|
||||
}
|
||||
}
|
||||
return days < MIN_DAYS || mj <= 0 ? null : kwh / mj;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The gateway's answer to a refresh: the devices it will poll again, and those polled too recently. */
|
||||
public record RefreshResult(List<String> requested, List<Skipped> skipped) {
|
||||
|
||||
public record Skipped(String device, long retryInSeconds) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
}
|
||||
@@ -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,9 @@
|
||||
package home.backend.domain;
|
||||
|
||||
/** One of the four writable setpoints as the gateway reports it; {@code value} is null until it has been read. */
|
||||
public record Setpoint(String name, int register, int min, int max, int step, Integer value) {
|
||||
|
||||
public boolean allows(int candidate) {
|
||||
return candidate >= min && candidate <= max && (candidate - min) % step == 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** What the gateway knows about the setpoints: their limits, values and the pending restores. */
|
||||
public record SetpointState(List<Setpoint> setpoints, List<Restore> restores) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The setpoints screen: gateway state plus the presets from the backend config. */
|
||||
public record SetpointsView(List<Setpoint> setpoints, List<Restore> restores, List<Preset> presets) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"id": "cwu-now",
|
||||
"name": "Ciepła woda teraz",
|
||||
"changes": [ { "setpoint": "cwu_economy", "value": 45, "hours": 2 } ]
|
||||
},
|
||||
{
|
||||
"id": "away",
|
||||
"name": "Wyjazd",
|
||||
"until": "chosen",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
export async function getJson(url) {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`${url}: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// A write is sent once and never retried. Resolves with the {status, message, restoreAt}
|
||||
// answer, also for a refusal; rejects when the gateway is unreachable (503) or the network fails.
|
||||
export async function postJson(url, body) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
if (response.status === 503) throw new Error(`${url}: 503`);
|
||||
const answer = await response.json().catch(() => null);
|
||||
if (!answer?.status) throw new Error(`${url}: ${response.status}`);
|
||||
return answer;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { ageMinutes, ago } from "./format.js";
|
||||
import heatPump from "./heat-pump.js";
|
||||
import energy from "./energy.js";
|
||||
import history from "./history.js";
|
||||
import settings from "./settings.js";
|
||||
import status from "./status.js";
|
||||
|
||||
const REFRESH_MS = 30000;
|
||||
const STALE_MINUTES = 10;
|
||||
const screens = { "heat-pump": heatPump, energy, history, settings, status };
|
||||
const root = document.getElementById("screen");
|
||||
const tabs = document.querySelectorAll("#tabs button");
|
||||
const cache = {}; // last good data per screen, kept while the backend is unreachable
|
||||
|
||||
let current = screens[location.hash.slice(1)] ? location.hash.slice(1) : "heat-pump";
|
||||
|
||||
function notice(offline, data) {
|
||||
if (offline) {
|
||||
const last = data
|
||||
? ` Pokazuję ostatnie dane (${ago(data.time)}).`
|
||||
: " Nie można pobrać danych z domu. Aplikacja spróbuje znowu za chwilę.";
|
||||
return `<div class="banner bad" role="alert">Brak połączenia.${last}</div>`;
|
||||
}
|
||||
if (data.time && ageMinutes(data.time) >= STALE_MINUTES) {
|
||||
return `<div class="banner warn">Dane są nieaktualne: ${ago(data.time)}.</div>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function draw(name, offline) {
|
||||
if (name !== current) return;
|
||||
const data = cache[name];
|
||||
root.innerHTML = notice(offline, data) + (data ? screens[name].view(data) : "");
|
||||
screens[name].bind?.(root, data, () => refresh(name));
|
||||
}
|
||||
|
||||
async function refresh(name) {
|
||||
let offline = false;
|
||||
try {
|
||||
cache[name] = await screens[name].load();
|
||||
} catch {
|
||||
offline = true;
|
||||
}
|
||||
draw(name, offline);
|
||||
}
|
||||
|
||||
function show(name) {
|
||||
current = name;
|
||||
location.hash = name;
|
||||
for (const tab of tabs) {
|
||||
if (tab.dataset.screen === name) tab.setAttribute("aria-current", "page");
|
||||
else tab.removeAttribute("aria-current");
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
if (cache[name]) draw(name, false);
|
||||
else root.innerHTML = `<p class="muted">Ładowanie…</p>`;
|
||||
refresh(name);
|
||||
}
|
||||
|
||||
for (const tab of tabs) tab.addEventListener("click", () => show(tab.dataset.screen));
|
||||
setInterval(() => refresh(current), REFRESH_MS);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) refresh(current);
|
||||
});
|
||||
show(current);
|
||||
@@ -1,100 +0,0 @@
|
||||
import { number, hour, dayAndHour } from "./format.js";
|
||||
|
||||
const W = 340, H = 220;
|
||||
const MARGIN = { left: 48, right: 22, top: 24, bottom: 26 };
|
||||
const Y_TICKS = 4; // about; the step is rounded
|
||||
const DAY_NAMES = ["nd", "pn", "wt", "śr", "cz", "pt", "sb"];
|
||||
const DAY_MS = 86400000;
|
||||
const HOURS_BETWEEN_TICKS = 6;
|
||||
|
||||
// Local midnights (or every 6th full hour) inside the range, so the axis reads like a clock.
|
||||
function timeTicks(t0, t1, range) {
|
||||
const ticks = [];
|
||||
const date = new Date(t0);
|
||||
date.setMinutes(0, 0, 0);
|
||||
if (range === "7d") date.setHours(0);
|
||||
else date.setHours(Math.ceil(date.getHours() / HOURS_BETWEEN_TICKS) * HOURS_BETWEEN_TICKS);
|
||||
for (; date.getTime() <= t1; range === "7d" ? date.setDate(date.getDate() + 1) : date.setHours(date.getHours() + HOURS_BETWEEN_TICKS)) {
|
||||
if (date.getTime() >= t0) ticks.push(date.getTime());
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
// 1, 2 or 5 times a power of ten, so the value labels are round numbers.
|
||||
function niceStep(rough) {
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rough));
|
||||
return [1, 2, 5, 10].map((m) => m * magnitude).find((s) => s >= rough);
|
||||
}
|
||||
|
||||
const xText = (px, anchor, text) => `<text class="sub" x="${px}" y="${H - 6}" text-anchor="${anchor}">${text}</text>`;
|
||||
|
||||
function hourLabels(ticks, x) {
|
||||
return ticks.map((t) => xText(x(t), "middle", hour(new Date(t)))).join("");
|
||||
}
|
||||
|
||||
// A weekday name sits in the middle of its day, between two midnight lines.
|
||||
function dayLabels(midnights, t0, t1, x) {
|
||||
const edges = [t0, ...midnights, t1];
|
||||
return edges.slice(0, -1).map((from, i) => {
|
||||
const to = edges[i + 1];
|
||||
if (to - from < DAY_MS / 3) return "";
|
||||
return xText((x(from) + x(to)) / 2, "middle", DAY_NAMES[new Date(from + 1).getDay()]);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// Time and value axes for a series of {t, v} points; returns the scales and the SVG markup.
|
||||
export function lineChart(points, range, unit) {
|
||||
const times = points.map((p) => Date.parse(p.t));
|
||||
const values = points.map((p) => p.v);
|
||||
const [t0, t1] = [Math.min(...times), Math.max(...times)];
|
||||
let [lo, hi] = [Math.min(...values), Math.max(...values)];
|
||||
if (hi - lo < 1) [lo, hi] = [lo - 0.5, hi + 0.5];
|
||||
const step = niceStep((hi - lo) / Y_TICKS);
|
||||
[lo, hi] = [Math.floor(lo / step) * step, Math.ceil(hi / step) * step];
|
||||
|
||||
const x = (t) => MARGIN.left + ((t - t0) / (t1 - t0 || 1)) * (W - MARGIN.left - MARGIN.right);
|
||||
const y = (v) => H - MARGIN.bottom - ((v - lo) / (hi - lo)) * (H - MARGIN.top - MARGIN.bottom);
|
||||
|
||||
const digits = step < 1 ? 1 : 0;
|
||||
const yLabels = Array.from({ length: Math.round((hi - lo) / step) + 1 }, (_, i) => {
|
||||
const v = lo + i * step;
|
||||
return `<line class="axis" x1="${MARGIN.left}" x2="${W - MARGIN.right}" y1="${y(v)}" y2="${y(v)}"/>
|
||||
<text class="sub" x="${MARGIN.left - 6}" y="${y(v) + 4}" text-anchor="end">${number(v, digits)}</text>`;
|
||||
}).join("");
|
||||
const ticks = timeTicks(t0, t1, range);
|
||||
const grid = ticks.map((t) => `<line class="axis" x1="${x(t)}" x2="${x(t)}" y1="${MARGIN.top}" y2="${H - MARGIN.bottom}"/>`).join("");
|
||||
const xLabels = range === "7d" ? dayLabels(ticks, t0, t1, x) : hourLabels(ticks, x);
|
||||
const path = points.map((p, i) => `${i ? "L" : "M"}${x(times[i]).toFixed(1)} ${y(p.v).toFixed(1)}`).join("");
|
||||
|
||||
const svg = `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="Wykres">
|
||||
<text class="sub" x="2" y="12">${unit}</text>
|
||||
${yLabels}${grid}${xLabels}<path class="line" d="${path}"/>
|
||||
<g id="cursor" hidden><line class="cursor" y1="${MARGIN.top}" y2="${H - MARGIN.bottom}"/>
|
||||
<circle class="mark" r="5"/></g></svg>`;
|
||||
return { svg, x, y, min: Math.min(...values), max: Math.max(...values) };
|
||||
}
|
||||
|
||||
// Shows the point nearest to the finger or mouse in the readout element.
|
||||
export function trackPointer(svg, readout, chart, { points, unit }) {
|
||||
const cursor = svg.querySelector("#cursor");
|
||||
const times = points.map((p) => Date.parse(p.t));
|
||||
const show = (event) => {
|
||||
const box = svg.getBoundingClientRect();
|
||||
const px = ((event.clientX - box.left) / box.width) * W;
|
||||
let best = 0;
|
||||
times.forEach((time, i) => {
|
||||
if (Math.abs(chart.x(time) - px) < Math.abs(chart.x(times[best]) - px)) best = i;
|
||||
});
|
||||
const [cx, cy] = [chart.x(times[best]), chart.y(points[best].v)];
|
||||
cursor.hidden = false;
|
||||
cursor.querySelector("line").setAttribute("x1", cx);
|
||||
cursor.querySelector("line").setAttribute("x2", cx);
|
||||
cursor.querySelector("circle").setAttribute("cx", cx);
|
||||
cursor.querySelector("circle").setAttribute("cy", cy);
|
||||
readout.textContent = `${dayAndHour(new Date(times[best]))}: ${number(points[best].v, unit === "W" || unit === "%" ? 0 : 1)} ${unit}`;
|
||||
};
|
||||
svg.addEventListener("pointerdown", show);
|
||||
svg.addEventListener("pointermove", (event) => {
|
||||
if (event.buttons || event.pointerType === "mouse") show(event);
|
||||
});
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { getJson } from "./api.js";
|
||||
import { kwh, percent, power } from "./format.js";
|
||||
|
||||
const FLOWING_W = 10;
|
||||
|
||||
// Nodes sit around the house; an arrow runs from the source to the sink of each flow.
|
||||
const NODES = {
|
||||
pv: { x: 68, y: 40, label: "Słońce" },
|
||||
grid: { x: 292, y: 40, label: "Sieć" },
|
||||
house: { x: 180, y: 125, label: "Dom" },
|
||||
battery: { x: 180, y: 215, label: "Bateria" },
|
||||
};
|
||||
|
||||
function node(id, value) {
|
||||
const { x, y, label } = NODES[id];
|
||||
return `<rect class="box" x="${x - 58}" y="${y - 30}" width="116" height="60" rx="10"/>
|
||||
<text x="${x}" y="${y - 7}" text-anchor="middle" font-size="15" class="sub">${label}</text>
|
||||
<text x="${x}" y="${y + 18}" text-anchor="middle" font-size="20" font-weight="700">${value}</text>`;
|
||||
}
|
||||
|
||||
function flow(from, to, watts) {
|
||||
if (watts == null || Math.abs(watts) < FLOWING_W) return "";
|
||||
const [source, sink] = watts > 0 ? [from, to] : [to, from];
|
||||
const a = NODES[source], b = NODES[sink];
|
||||
const length = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const t1 = 40 / length, t2 = 1 - 46 / length;
|
||||
const point = (t, axis) => a[axis] + (b[axis] - a[axis]) * t;
|
||||
return `<line class="flow" x1="${point(t1, "x")}" y1="${point(t1, "y")}"
|
||||
x2="${point(t2, "x")}" y2="${point(t2, "y")}" marker-end="url(#arrow)"/>`;
|
||||
}
|
||||
|
||||
function flowPicture(e) {
|
||||
return `<svg class="schematic" viewBox="0 0 360 260" role="img" aria-label="Przepływ energii w domu">
|
||||
<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5"
|
||||
orient="auto"><path d="M0 0 10 5 0 10z" fill="var(--accent)"/></marker></defs>
|
||||
${flow("pv", "house", e.pvPowerW)}${flow("grid", "house", e.gridPowerW)}
|
||||
${flow("battery", "house", e.batteryPowerW)}
|
||||
${node("pv", power(e.pvPowerW))}${node("grid", power(magnitude(e.gridPowerW)))}
|
||||
${node("house", power(e.loadPowerW))}${node("battery", percent(e.batterySocPct))}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const magnitude = (watts) => (watts == null ? null : Math.abs(watts));
|
||||
|
||||
function gridText(watts) {
|
||||
if (watts == null) return "—";
|
||||
if (Math.abs(watts) < FLOWING_W) return "bez wymiany";
|
||||
return watts > 0 ? "pobór" : "oddawanie";
|
||||
}
|
||||
|
||||
function batteryText(watts) {
|
||||
if (watts == null) return "—";
|
||||
if (Math.abs(watts) < FLOWING_W) return "bez przepływu";
|
||||
return watts > 0 ? `rozładowuje się, ${power(watts)}` : `ładuje się, ${power(-watts)}`;
|
||||
}
|
||||
|
||||
const tile = (label, value, note = "") =>
|
||||
`<div class="tile"><div class="muted">${label}</div><div class="big">${value}</div>
|
||||
<div class="muted">${note}</div></div>`;
|
||||
|
||||
function view(e) {
|
||||
const today = e.today;
|
||||
return `<section class="card"><h1>Energia teraz</h1>${flowPicture(e)}</section>
|
||||
<section class="card"><div class="grid2">
|
||||
${tile("Produkcja PV", power(e.pvPowerW), "Deye + Fronius")}
|
||||
${tile("Zużycie domu", power(e.loadPowerW))}
|
||||
${tile("Sieć", power(magnitude(e.gridPowerW)), gridText(e.gridPowerW))}
|
||||
${tile("Bateria", percent(e.batterySocPct), batteryText(e.batteryPowerW))}
|
||||
</div></section>
|
||||
<section class="card"><h2>Dzisiaj</h2><div class="grid2">
|
||||
${tile("Produkcja PV", kwh(today.pvKwh))}
|
||||
${tile("Zużycie", kwh(today.loadKwh))}
|
||||
${tile("Pobrano z sieci", kwh(today.gridBoughtKwh))}
|
||||
${tile("Oddano do sieci", kwh(today.gridSoldKwh))}
|
||||
</div></section>`;
|
||||
}
|
||||
|
||||
export default { load: () => getJson("api/energy"), view };
|
||||
@@ -1,39 +0,0 @@
|
||||
// Polish number and time formats; every function accepts null and shows a dash.
|
||||
const DASH = "—";
|
||||
|
||||
const decimals = (digits) => new Intl.NumberFormat("pl-PL", {
|
||||
minimumFractionDigits: digits, maximumFractionDigits: digits,
|
||||
});
|
||||
const formats = [decimals(0), decimals(1), decimals(2)];
|
||||
|
||||
// The typographic minus is wider than the hyphen, so -3 is not read as a dash or a range.
|
||||
export function number(value, digits = 0) {
|
||||
return value == null ? DASH : formats[digits].format(value).replace("-", "\u2212");
|
||||
}
|
||||
|
||||
export const celsius = (value) => (value == null ? DASH : `${number(value, 1)} °C`);
|
||||
export const percent = (value) => (value == null ? DASH : `${number(value)} %`);
|
||||
export const kwh = (value) => (value == null ? DASH : `${number(value, 1)} kWh`);
|
||||
|
||||
export function power(watts) {
|
||||
if (watts == null) return DASH;
|
||||
return Math.abs(watts) >= 1000 ? `${number(watts / 1000, 1)} kW` : `${number(watts)} W`;
|
||||
}
|
||||
|
||||
export function ageMinutes(iso, now = Date.now()) {
|
||||
return Math.max(0, Math.floor((now - Date.parse(iso)) / 60000));
|
||||
}
|
||||
|
||||
export function ago(iso) {
|
||||
if (iso == null) return "brak odczytu";
|
||||
const minutes = ageMinutes(iso);
|
||||
if (minutes < 1) return "przed chwilą";
|
||||
if (minutes < 60) return `${minutes} min temu`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 48) return `${hours} godz. temu`;
|
||||
return `${Math.floor(hours / 24)} dni temu`;
|
||||
}
|
||||
|
||||
export const hour = (date) => date.toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" });
|
||||
export const weekday = (date) => date.toLocaleDateString("pl-PL", { weekday: "short" });
|
||||
export const dayAndHour = (date) => `${weekday(date)} ${hour(date)}`;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { getJson } from "./api.js";
|
||||
import { celsius, hour } from "./format.js";
|
||||
import { schematic } from "./schematic.js";
|
||||
|
||||
const HEADLINES = {
|
||||
IDLE: "Pompa odpoczywa",
|
||||
HEATING_CWU: "Grzeje wodę (CWU)",
|
||||
HEATING_CO: "Grzeje dom (CO)",
|
||||
HEATING_BOTH: "Grzeje dom i wodę",
|
||||
};
|
||||
|
||||
const PUMP_NAMES = {
|
||||
ground: "Pompa gruntowa", buffer: "Pompa bufora", co: "Pompa CO",
|
||||
cwu: "Pompa CWU", circulation: "Pompa cyrkulacji",
|
||||
};
|
||||
|
||||
const row = (label, value) => `<div><span>${label}</span><b>${value}</b></div>`;
|
||||
|
||||
function pumpRows(pumps) {
|
||||
return Object.entries(PUMP_NAMES)
|
||||
.map(([key, name]) => row(name, pumps[key] ? "działa" : "stoi")).join("");
|
||||
}
|
||||
|
||||
function view(data) {
|
||||
const { targets, temperatures: t } = data;
|
||||
const alarm = data.alarm
|
||||
? `<div class="banner bad" role="alert">ALARM pompy ciepła. Sprawdź sterownik w domu.</div>` : "";
|
||||
return `${alarm}
|
||||
<section class="card">
|
||||
<p class="headline">${HEADLINES[data.state]}</p>
|
||||
<p class="muted">Odczyt z ${hour(new Date(data.time))}</p>
|
||||
${schematic(data)}
|
||||
</section>
|
||||
<section class="card rows"><h2>Pompy</h2>${pumpRows(data.pumps)}</section>
|
||||
<section class="card rows"><h2>Temperatury</h2>
|
||||
${row("Na zewnątrz", celsius(t.outdoor))}
|
||||
${row("Średnia zewnętrzna", celsius(t.outdoorAvg))}
|
||||
</section>
|
||||
<section class="card rows"><h2>Cele teraz</h2>
|
||||
${row("Pokój", celsius(targets.room))}
|
||||
${row("Woda grzewcza CO", celsius(targets.co))}
|
||||
${row("Ciepła woda CWU", celsius(targets.cwu))}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export default { load: () => getJson("api/heat-pump"), view };
|
||||
@@ -1,61 +0,0 @@
|
||||
import { getJson } from "./api.js";
|
||||
import { number } from "./format.js";
|
||||
import { lineChart, trackPointer } from "./chart.js";
|
||||
|
||||
// Ids as in Series.java.
|
||||
const SERIES = {
|
||||
outdoor_temp: "Temperatura na zewnątrz",
|
||||
indoor_temp: "Temperatura w domu",
|
||||
co_buffer_temp: "Bufor CO",
|
||||
cwu_temp: "Ciepła woda (CWU)",
|
||||
ground_in_temp: "Gruntowe: do pompy",
|
||||
ground_out_temp: "Gruntowe: z pompy",
|
||||
pump_outlet_temp: "Wyjście pompy ciepła",
|
||||
grid_power: "Moc z sieci (+ pobór, − oddawanie)",
|
||||
pv_power: "Produkcja PV",
|
||||
load_power: "Zużycie domu",
|
||||
battery_soc: "Bateria (naładowanie)",
|
||||
};
|
||||
const RANGES = { "24h": "24 godziny", "7d": "7 dni" };
|
||||
|
||||
const choice = { series: "cwu_temp", range: "24h" };
|
||||
let chart = null; // the one drawn by the last view, for the pointer handler
|
||||
|
||||
const options = () => Object.entries(SERIES)
|
||||
.map(([id, name]) => `<option value="${id}"${id === choice.series ? " selected" : ""}>${name}</option>`).join("");
|
||||
|
||||
const rangeButtons = () => Object.entries(RANGES)
|
||||
.map(([id, name]) => `<button data-range="${id}" aria-pressed="${id === choice.range}">${name}</button>`).join("");
|
||||
|
||||
function body(data) {
|
||||
chart = null;
|
||||
if (data.points.length < 2) return `<p class="muted">Za mało danych w tym okresie.</p>`;
|
||||
chart = lineChart(data.points, choice.range, data.unit);
|
||||
const digits = data.unit === "°C" ? 1 : 0;
|
||||
return `${chart.svg}
|
||||
<p class="readout" id="readout">Dotknij wykresu, aby zobaczyć wartość.</p>
|
||||
<p class="muted">Najmniej ${number(chart.min, digits)} ${data.unit} · najwięcej ${number(chart.max, digits)} ${data.unit}</p>`;
|
||||
}
|
||||
|
||||
const view = (data) => `<section class="card"><h1>Historia</h1>
|
||||
<select id="series" aria-label="Wielkość">${options()}</select>
|
||||
<div class="seg">${rangeButtons()}</div>
|
||||
${body(data)}</section>`;
|
||||
|
||||
function bind(root, data, reload) {
|
||||
root.querySelector("#series").addEventListener("change", (event) => {
|
||||
choice.series = event.target.value;
|
||||
reload();
|
||||
});
|
||||
for (const button of root.querySelectorAll("[data-range]")) {
|
||||
button.addEventListener("click", () => {
|
||||
choice.range = button.dataset.range;
|
||||
reload();
|
||||
});
|
||||
}
|
||||
if (chart) trackPointer(root.querySelector("svg.chart"), root.querySelector("#readout"), chart, data);
|
||||
}
|
||||
|
||||
const load = () => getJson(`api/history?series=${choice.series}&range=${choice.range}`);
|
||||
|
||||
export default { load, view, bind };
|
||||
@@ -1,22 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Dom Krosno</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script type="module" src="app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main id="screen" aria-live="polite"></main>
|
||||
<nav id="tabs" aria-label="Ekrany">
|
||||
<button data-screen="heat-pump"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3c3 4 5 6.5 5 10a5 5 0 0 1-10 0c0-2 1-3.5 2-5 1 1 1.5 2 2 2.5C11.5 8.5 11 6 12 3z"/></svg>Pompa ciepła</button>
|
||||
<button data-screen="energy"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 5 13h6l-1 9 8-11h-6z"/></svg>Energia</button>
|
||||
<button data-screen="history"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 18 9 11l4 4 8-10" fill="none" stroke-width="2.5"/></svg>Historia</button>
|
||||
<button data-screen="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10" fill="none" stroke-width="2.5"/><circle cx="16" cy="7" r="2.5"/><circle cx="8" cy="17" r="2.5"/></svg>Ustawienia</button>
|
||||
<button data-screen="status"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke-width="2.5"/><path d="m8 12 3 3 5-6" fill="none" stroke-width="2.5"/></svg>Stan</button>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,47 +0,0 @@
|
||||
import { celsius, number } from "./format.js";
|
||||
|
||||
// The installation as one picture: ground loop → heat pump → CO buffer → house, and the CWU
|
||||
// tank. The solar collector has no sensor, so it is drawn without values.
|
||||
|
||||
const on = (flag) => (flag ? " on" : "");
|
||||
|
||||
function pump(x, y, running) {
|
||||
return `<g class="${running ? "on" : ""}" transform="translate(${x} ${y})">
|
||||
<circle class="pump" r="13"/>
|
||||
<path class="spokes" d="M-8 0H8M0-8V8"/></g>`;
|
||||
}
|
||||
|
||||
function box(x, y, width, height, lines) {
|
||||
const text = lines.map(([content, cls], i) =>
|
||||
`<text x="${x + 10}" y="${y + 22 + i * 20}" class="${cls ?? ""}" font-size="${cls ? 15.5 : 17}"
|
||||
font-weight="${cls ? 400 : 700}">${content}</text>`).join("");
|
||||
return `<rect class="box" x="${x}" y="${y}" width="${width}" height="${height}" rx="10"/>${text}`;
|
||||
}
|
||||
|
||||
export function schematic(view) {
|
||||
const { temperatures: t, pumps, state } = view;
|
||||
const heatsCo = state === "HEATING_CO" || state === "HEATING_BOTH";
|
||||
const heatsCwu = state === "HEATING_CWU" || state === "HEATING_BOTH";
|
||||
const compressor = view.compressorPercent == null ? "—" : `${number(view.compressorPercent)} %`;
|
||||
return `<svg class="schematic" viewBox="0 0 360 460" role="img"
|
||||
aria-label="Schemat instalacji: odwierty, pompa ciepła, bufor CO, dom i zbiornik CWU">
|
||||
<path class="pipe cold${on(pumps.ground)}" d="M150 78V130M210 78V130"/>
|
||||
<path class="pipe${on(heatsCo)}" d="M100 218V262M100 346V376"/>
|
||||
<path class="pipe${on(heatsCwu)}" d="M260 218V262"/>
|
||||
${box(15, 10, 330, 68, [["Odwierty (dolne źródło)"],
|
||||
[`Do pompy ${celsius(t.groundIn)}`, "sub"], [`Z pompy ${celsius(t.groundOut)}`, "sub"]])}
|
||||
${box(15, 130, 330, 90, [["Pompa ciepła"],
|
||||
[`Sprężarka ${compressor} · grzałka ${view.heaterOn ? "włączona" : "wyłączona"}`, "sub"],
|
||||
[`Wyjście pompy ${celsius(t.pumpOutlet)}`, "sub"]])}
|
||||
${box(15, 262, 165, 84, [["Bufor CO"], [celsius(t.coBuffer), "sub"], [`Cel ${celsius(view.targets.co)}`, "sub"]])}
|
||||
${box(190, 262, 155, 84, [["Zbiornik CWU"], [celsius(t.cwu), "sub"], [`Cel ${celsius(view.targets.cwu)}`, "sub"]])}
|
||||
${box(15, 376, 165, 70, [["Dom (grzejniki)"], [`Wewnątrz ${celsius(t.indoor)}`, "sub"], [`Cel ${celsius(view.targets.room)}`, "sub"]])}
|
||||
${box(190, 376, 155, 70, [["Kolektor"], ["na dachu", "sub"], ["bez czujnika", "sub"]])}
|
||||
<path class="pipe" d="M270 346V376" stroke-dasharray="3 7"/>
|
||||
${pump(210, 104, pumps.ground)}
|
||||
${pump(100, 240, pumps.buffer)}
|
||||
${pump(100, 361, pumps.co)}
|
||||
${pump(260, 240, pumps.cwu)}
|
||||
${pump(145, 361, pumps.circulation)}
|
||||
</svg>`;
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { getJson, postJson } from "./api.js";
|
||||
import { number, hour, dayAndHour } from "./format.js";
|
||||
|
||||
const NAMES = {
|
||||
cwu_economy: "Ciepła woda, oszczędnie",
|
||||
cwu_comfort: "Ciepła woda, komfort",
|
||||
co_economy: "Ogrzewanie domu, oszczędnie",
|
||||
co_comfort: "Ogrzewanie domu, komfort",
|
||||
};
|
||||
const DURATIONS = [
|
||||
{ hours: 1, label: "1 godz." }, { hours: 2, label: "2 godz." },
|
||||
{ hours: 4, label: "4 godz." }, { hours: null, label: "Do odwołania" },
|
||||
];
|
||||
const OFFLINE = "Dom nie odpowiada, spróbuj za chwilę.";
|
||||
const RESULT_TEXT = {
|
||||
REJECTED: "Pompa nie przyjęła tej zmiany. Nic nie zostało zmienione.",
|
||||
FAILED: "Nie wiadomo, czy zmiana doszła do pompy. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
|
||||
};
|
||||
|
||||
// Screen state outlives the 30 s refresh, so a half-made change is not lost.
|
||||
// draft: the one setpoint being changed; confirming: the summary is shown, waiting for "Tak".
|
||||
const state = { draft: null, hours: 2, preset: null, confirming: false, busy: false, result: null };
|
||||
|
||||
const name = (id) => NAMES[id] ?? id;
|
||||
const degrees = (value) => `${number(value, 1).replace(/,0$/, "")} °C`;
|
||||
const disabled = () => (state.busy ? " disabled" : "");
|
||||
const find = (data, id) => data.setpoints.find((s) => s.name === id);
|
||||
|
||||
function when(iso) {
|
||||
const date = new Date(iso);
|
||||
return date.toDateString() === new Date().toDateString() ? hour(date) : dayAndHour(date);
|
||||
}
|
||||
|
||||
function restores(data) {
|
||||
if (!data.restores.length) return "";
|
||||
const lines = data.restores.map((r) =>
|
||||
`<p>${name(r.setpoint)}: <b>${degrees(r.appliedValue)}</b> do ${when(r.due)}, potem <b>${degrees(r.originalValue)}</b>.</p>`);
|
||||
return `<section class="card"><h2>Zmiany czasowe</h2>${lines.join("")}</section>`;
|
||||
}
|
||||
|
||||
function resultBox() {
|
||||
const r = state.result;
|
||||
if (!r) return "";
|
||||
if (r.status === "APPLIED") {
|
||||
const back = r.restoreAt ? ` Wrócimy do poprzedniej wartości o ${when(r.restoreAt)}.` : "";
|
||||
return `<div class="banner ok" role="status">Zapisano.${back}</div>`;
|
||||
}
|
||||
const detail = r.message ? `<br><span class="detail">${r.message}</span>` : "";
|
||||
return `<div class="banner bad" role="alert">${r.text ?? RESULT_TEXT[r.status]}${detail}</div>`;
|
||||
}
|
||||
|
||||
function confirmButtons(yes, action) {
|
||||
return `<div class="seg"><button data-act="cancel"${disabled()}>Anuluj</button>
|
||||
<button class="primary" data-act="${action}"${disabled()}>${yes}</button></div>`;
|
||||
}
|
||||
|
||||
function presets(data) {
|
||||
const buttons = data.presets.map((p) =>
|
||||
`<button class="big" data-preset="${p.id}"${disabled()}>${p.label}</button>`).join("");
|
||||
return `<section class="card"><h2>Szybkie ustawienia</h2><div class="stack">${buttons}</div></section>`;
|
||||
}
|
||||
|
||||
function presetConfirm(data) {
|
||||
const preset = data.presets.find((p) => p.id === state.preset);
|
||||
const effect = preset.description ? `<p class="lead">${preset.description}</p>` : "";
|
||||
return `<section class="card"><h2>${preset.label}</h2>${effect}<p>Potwierdzasz?</p>
|
||||
${confirmButtons("Tak, ustaw", "preset-ok")}</section>`;
|
||||
}
|
||||
|
||||
function stepper(s) {
|
||||
const shown = state.draft?.name === s.name ? state.draft.value : s.value;
|
||||
const label = name(s.name);
|
||||
return `<div class="setpoint${shown === s.value ? "" : " changed"}"><span class="name">${label}</span>
|
||||
<div class="step">
|
||||
<button data-step="-1" data-name="${s.name}" aria-label="Mniej: ${label}"${disabled()}${shown <= s.min ? " disabled" : ""}>−</button>
|
||||
<output>${degrees(shown)}</output>
|
||||
<button data-step="1" data-name="${s.name}" aria-label="Więcej: ${label}"${disabled()}${shown >= s.max ? " disabled" : ""}>+</button>
|
||||
</div><span class="muted">od ${degrees(s.min)} do ${degrees(s.max)}</span></div>`;
|
||||
}
|
||||
|
||||
function saveBox(data) {
|
||||
const d = state.draft;
|
||||
if (!d) return "";
|
||||
const s = find(data, d.name);
|
||||
const options = DURATIONS.map((o) =>
|
||||
`<button data-hours="${o.hours ?? ""}" aria-pressed="${o.hours === state.hours}"${disabled()}>${o.label}</button>`).join("");
|
||||
const action = state.confirming
|
||||
? `<p class="lead">${saveSummary(s)}</p>${confirmButtons("Tak, zapisz", "save-ok")}`
|
||||
: `<div class="seg"><button data-act="cancel"${disabled()}>Anuluj</button>
|
||||
<button class="primary" data-act="save"${disabled()}>Zapisz</button></div>`;
|
||||
return `<div class="savebox"><p class="lead">${name(d.name)}: z ${degrees(s.value)} na <b>${degrees(d.value)}</b></p>
|
||||
<p>Na jak długo?</p><div class="seg wrap">${options}</div>${action}</div>`;
|
||||
}
|
||||
|
||||
function saveSummary(s) {
|
||||
const value = degrees(state.draft.value);
|
||||
if (!state.hours) return `Ustawię ${value} do odwołania. Poprzednia wartość (${degrees(s.value)}) nie wróci sama.`;
|
||||
const back = when(new Date(Date.now() + state.hours * 3600000).toISOString());
|
||||
return `Ustawię ${value}. O ${back} wrócimy do ${degrees(s.value)}.`;
|
||||
}
|
||||
|
||||
function body(data) {
|
||||
if (state.preset) return resultBox() + presetConfirm(data);
|
||||
return `${resultBox()}${restores(data)}${presets(data)}
|
||||
<section class="card"><h2>Nastawy</h2>${data.setpoints.map(stepper).join("")}${saveBox(data)}</section>`;
|
||||
}
|
||||
|
||||
const view = (data) => `<div id="settings">${body(data)}</div>`;
|
||||
|
||||
function change(data, el) {
|
||||
const { step, name: id, hours, preset, act } = el.dataset;
|
||||
state.result = null;
|
||||
if (step) {
|
||||
const s = find(data, id);
|
||||
const from = state.draft?.name === id ? state.draft.value : s.value;
|
||||
const value = Math.min(s.max, Math.max(s.min, from + Number(step) * s.step));
|
||||
state.draft = value === s.value ? null : { name: id, value };
|
||||
state.confirming = false;
|
||||
} else if (hours !== undefined) {
|
||||
state.hours = hours === "" ? null : Number(hours);
|
||||
state.confirming = false;
|
||||
} else if (preset) {
|
||||
state.preset = preset;
|
||||
} else if (act === "cancel") {
|
||||
Object.assign(state, { draft: null, preset: null, confirming: false });
|
||||
} else if (act === "save") {
|
||||
state.confirming = true;
|
||||
}
|
||||
}
|
||||
|
||||
function bind(root, data, reload) {
|
||||
const box = root.querySelector("#settings");
|
||||
|
||||
async function send(url, payload) {
|
||||
state.busy = true;
|
||||
box.innerHTML = body(data);
|
||||
try {
|
||||
state.result = await postJson(url, payload);
|
||||
if (state.result.status === "APPLIED") state.draft = null;
|
||||
} catch {
|
||||
state.result = { status: "FAILED", text: OFFLINE };
|
||||
}
|
||||
Object.assign(state, { busy: false, preset: null, confirming: false });
|
||||
window.scrollTo(0, 0);
|
||||
reload();
|
||||
}
|
||||
|
||||
box.addEventListener("click", (event) => {
|
||||
const el = event.target.closest("button");
|
||||
if (!el || state.busy) return;
|
||||
const act = el.dataset.act;
|
||||
if (act === "save-ok") {
|
||||
const { name: setpoint, value } = state.draft;
|
||||
send("api/setpoints", { setpoint, value, hours: state.hours ?? undefined });
|
||||
} else if (act === "preset-ok") {
|
||||
send(`api/presets/${state.preset}`);
|
||||
} else {
|
||||
change(data, el);
|
||||
box.innerHTML = body(data);
|
||||
box.querySelector(".savebox")?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default { load: () => getJson("api/setpoints"), view, bind };
|
||||
@@ -1,32 +0,0 @@
|
||||
import { getJson } from "./api.js";
|
||||
import { ago } from "./format.js";
|
||||
|
||||
const DEVICE_NAMES = {
|
||||
"heat-pump": "Pompa ciepła",
|
||||
"house-meter": "Licznik domu",
|
||||
bme280: "Czujnik pogody",
|
||||
deye: "Falownik Deye (bateria, PV)",
|
||||
fronius: "Falownik Fronius (PV)",
|
||||
};
|
||||
|
||||
const dot = (up) => `<span class="dot ${up ? "" : "down"}" aria-hidden="true"></span>`;
|
||||
|
||||
function device(d) {
|
||||
const last = d.lastSuccess ? `ostatni odczyt ${ago(d.lastSuccess)}` : "brak odczytu";
|
||||
const detail = d.up ? last : `nie odpowiada, ${last}`;
|
||||
return `<div><span>${dot(d.up)}${DEVICE_NAMES[d.name] ?? d.name}<br>
|
||||
<span class="muted">${detail}</span></span><b>${d.up ? "działa" : "brak"}</b></div>`;
|
||||
}
|
||||
|
||||
const devices = (data) => (data.devices.length
|
||||
? data.devices.map(device).join("")
|
||||
: `<p class="muted">Lista pojawi się, gdy dom odpowie.</p>`);
|
||||
|
||||
function view(data) {
|
||||
const gateway = data.gatewayReachable
|
||||
? `${dot(true)}Dom odpowiada` : `${dot(false)}Dom nie odpowiada`;
|
||||
return `<section class="card"><h2>Połączenie z domem</h2><p class="headline">${gateway}</p></section>
|
||||
<section class="card rows"><h2>Urządzenia</h2>${devices(data)}</section>`;
|
||||
}
|
||||
|
||||
export default { load: () => getJson("api/health"), view };
|
||||
@@ -1,102 +0,0 @@
|
||||
:root {
|
||||
--bg: #f1f5f2; --card: #ffffff; --text: #14201a; --muted: #4a5a52; --line: #cfd9d3;
|
||||
--accent: #0d6b45; --on-accent: #ffffff; --ok: #0d6b45; --warn: #8a5a00; --bad: #b3261e;
|
||||
--warn-bg: #fff1cc; --bad-bg: #fde0dd; --hot: #c8501a; --cold: #1b6fb5; --off: #8d9a93;
|
||||
--box: #e8f0eb; --ok-bg: #d9f2e4;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f1713; --card: #19241e; --text: #f0f6f2; --muted: #b4c4ba; --line: #34463c;
|
||||
--accent: #4fcf94; --on-accent: #06210f; --ok: #4fcf94; --warn: #f2c14e; --bad: #ff8a80;
|
||||
--warn-bg: #3d3210; --bad-bg: #4a1f1c; --hot: #ff9a62; --cold: #6bb6f2; --off: #7b8b82;
|
||||
--box: #223229; --ok-bg: #153d2a;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { font-size: 18px; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: system-ui, "Segoe UI", Roboto, sans-serif; line-height: 1.35;
|
||||
}
|
||||
main { max-width: 640px; margin: 0 auto; padding: 16px 12px 96px; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
h2 { font-size: 1.05rem; margin: 0 0 8px; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; margin-bottom: 12px; }
|
||||
.muted { color: var(--muted); font-size: 0.85rem; }
|
||||
.headline { font-size: 1.5rem; font-weight: 700; margin: 0 0 4px; }
|
||||
.banner { border-radius: 12px; padding: 12px 14px; margin-bottom: 12px; font-weight: 600; }
|
||||
.banner.bad { background: var(--bad-bg); color: var(--bad); border: 2px solid var(--bad); }
|
||||
.banner.warn { background: var(--warn-bg); color: var(--warn); border: 2px solid var(--warn); }
|
||||
.rows > div { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid var(--line); }
|
||||
.rows > div:first-child { border-top: 0; }
|
||||
.rows b { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.tile { background: var(--box); border-radius: 12px; padding: 12px; }
|
||||
.tile .big { font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.dot { display: inline-block; width: 14px; height: 14px; border-radius: 50%; margin-right: 8px; vertical-align: -1px; background: var(--ok); }
|
||||
.dot.down { background: var(--bad); }
|
||||
nav {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; display: flex; background: var(--card);
|
||||
border-top: 1px solid var(--line); padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
nav button {
|
||||
flex: 1; min-height: 64px; border: 0; background: none; color: var(--muted); font: inherit;
|
||||
font-size: 0.7rem; font-weight: 600; white-space: nowrap; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 2px; padding: 4px 2px;
|
||||
}
|
||||
nav svg { width: 26px; height: 26px; fill: currentColor; stroke: currentColor; stroke-linejoin: round; stroke-linecap: round; }
|
||||
nav button[aria-current="page"] { color: var(--accent); box-shadow: inset 0 3px 0 var(--accent); }
|
||||
button:focus-visible, select:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||
.seg { display: flex; gap: 8px; margin: 8px 0; }
|
||||
.seg button, select {
|
||||
flex: 1; min-height: 48px; border: 2px solid var(--line); background: var(--card); color: var(--text);
|
||||
border-radius: 12px; font: inherit; font-weight: 600;
|
||||
}
|
||||
select { width: 100%; padding: 0 10px; }
|
||||
.seg button[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
svg text { fill: var(--text); font-family: inherit; }
|
||||
svg .sub { fill: var(--muted); }
|
||||
.schematic { width: 100%; height: auto; display: block; }
|
||||
.box { fill: var(--box); stroke: var(--line); stroke-width: 1.5; }
|
||||
.pipe { stroke: var(--off); stroke-width: 5; fill: none; }
|
||||
.pipe.on { stroke: var(--hot); }
|
||||
.pipe.cold.on { stroke: var(--cold); }
|
||||
.pump { fill: var(--card); stroke: var(--off); stroke-width: 3; }
|
||||
.on .pump { stroke: var(--ok); }
|
||||
.spokes { stroke: var(--off); stroke-width: 3; transform-origin: center; transform-box: fill-box; }
|
||||
.on .spokes { stroke: var(--ok); animation: spin 2s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .on .spokes { animation: none; } }
|
||||
.flow { stroke: var(--accent); stroke-width: 4; }
|
||||
.chart text { font-size: 14.5px; }
|
||||
.chart [hidden] { display: none; }
|
||||
.chart .axis { stroke: var(--line); stroke-width: 1; }
|
||||
.chart .line { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linejoin: round; }
|
||||
.chart .cursor { stroke: var(--muted); stroke-width: 1; }
|
||||
.chart .mark { fill: var(--accent); }
|
||||
.chart { touch-action: pan-y; width: 100%; height: auto; display: block; }
|
||||
.readout { min-height: 2.7em; font-weight: 600; }
|
||||
.banner.ok { background: var(--ok-bg); color: var(--ok); border: 2px solid var(--ok); }
|
||||
.detail { font-weight: 400; font-size: 0.85rem; }
|
||||
.lead { font-size: 1.1rem; font-weight: 600; margin: 8px 0; }
|
||||
.stack { display: grid; gap: 10px; }
|
||||
button.big, .seg button.primary {
|
||||
min-height: 64px; border: 2px solid var(--accent); border-radius: 12px; font: inherit;
|
||||
font-size: 1.1rem; font-weight: 700; background: var(--card); color: var(--accent);
|
||||
}
|
||||
.seg button.primary { background: var(--accent); color: var(--on-accent); }
|
||||
button:disabled { opacity: 0.45; }
|
||||
.seg.wrap { flex-wrap: wrap; }
|
||||
.seg.wrap button { flex: 1 1 40%; }
|
||||
.setpoint { padding: 10px 0; border-top: 1px solid var(--line); }
|
||||
.setpoint:first-of-type { border-top: 0; }
|
||||
.setpoint .name { display: block; font-weight: 600; }
|
||||
.setpoint.changed { border-left: 5px solid var(--accent); padding-left: 10px; }
|
||||
.step { display: flex; align-items: center; gap: 10px; margin: 6px 0 2px; }
|
||||
.step button {
|
||||
width: 64px; height: 64px; border: 2px solid var(--accent); border-radius: 12px; background: var(--card);
|
||||
color: var(--accent); font: inherit; font-size: 2rem; font-weight: 700; line-height: 1;
|
||||
}
|
||||
.step output { flex: 1; text-align: center; font-size: 1.8rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.savebox { border-top: 2px solid var(--accent); margin-top: 8px; padding-top: 8px; }
|
||||
.savebox { scroll-margin-bottom: 84px; }
|
||||
@@ -2,11 +2,13 @@ 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.http.ApiServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
@@ -20,6 +22,8 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -36,6 +40,9 @@ class BackendTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
/** 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 final HttpClient client = HttpClient.newHttpClient();
|
||||
private HttpServer gateway;
|
||||
private HttpServer influx;
|
||||
@@ -46,12 +53,42 @@ class BackendTest {
|
||||
gateway = stub(Map.of(
|
||||
"/health", "src/test/resources/gateway/health-all-up.json",
|
||||
"/snapshot", "src/test/resources/gateway/snapshot-all-up.json"));
|
||||
gateway.createContext("/commands", this::commands);
|
||||
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
|
||||
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2));
|
||||
backend = Backend.assemble(settings, Clock.systemUTC());
|
||||
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
|
||||
"s3cret-token", null);
|
||||
backend = Backend.assemble(settings, CLOCK);
|
||||
backend.start();
|
||||
}
|
||||
|
||||
private record Command(String authorization, String requestedBy, String body) {
|
||||
}
|
||||
|
||||
private final List<Command> commandsSeen = new ArrayList<>();
|
||||
|
||||
/** The gateway's /commands, answering with the gateway's own contract examples. */
|
||||
private void commands(HttpExchange exchange) throws IOException {
|
||||
boolean post = exchange.getRequestMethod().equals("POST");
|
||||
if (post) {
|
||||
commandsSeen.add(new Command(exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
private HttpResponse<String> post(String path, String body) throws Exception {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
|
||||
.header("Remote-User", "anna").header("Content-Type", "application/json")
|
||||
.header("X-Requested-With", "home-pump")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
backend.stop();
|
||||
@@ -114,6 +151,11 @@ class BackendTest {
|
||||
assertServesExactly("/api/heat-pump", "heat-pump");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesPiSensorFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/pi-sensor", "pi-sensor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesEnergyFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/energy", "energy");
|
||||
@@ -141,16 +183,64 @@ class BackendTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheRealWebUi() throws Exception {
|
||||
HttpResponse<String> index = get("/");
|
||||
HttpResponse<String> script = get("/app.js");
|
||||
void servesSetpointsWithTheBuiltInPresets() throws Exception {
|
||||
JsonNode view = JSON.readTree(get("/api/setpoints").body());
|
||||
|
||||
assertEquals(200, index.statusCode());
|
||||
assertEquals("text/html; charset=utf-8", index.headers().firstValue("Content-Type").orElseThrow());
|
||||
assertEquals(Files.readString(Path.of("src/main/resources/web/index.html")), index.body());
|
||||
// Browsers refuse ES modules served with any other type.
|
||||
assertEquals(200, script.statusCode());
|
||||
assertEquals("text/javascript; charset=utf-8", script.headers().firstValue("Content-Type").orElseThrow());
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/setpoints.json"))), view);
|
||||
}
|
||||
|
||||
@Test
|
||||
void boostGoesToTheGatewayWithTokenAndUser() throws Exception {
|
||||
var response = post("/api/setpoints",
|
||||
Files.readString(Path.of("src/test/resources/api/setpoints-request.json")));
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/setpoints-applied.json"))),
|
||||
JSON.readTree(response.body()));
|
||||
assertEquals(1, commandsSeen.size());
|
||||
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
|
||||
assertEquals("anna", commandsSeen.getFirst().requestedBy());
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
|
||||
JSON.readTree(commandsSeen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetsComeFromPresetsFile(@TempDir Path dir) throws Exception {
|
||||
Path file = Files.writeString(dir.resolve("presets.json"),
|
||||
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
|
||||
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(2), "s3cret-token", file), CLOCK);
|
||||
custom.start();
|
||||
try {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
|
||||
.header("Content-Type", "application/json").header("X-Requested-With", "home-pump")
|
||||
.POST(HttpRequest.BodyPublishers.noBody()).build();
|
||||
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("phone", commandsSeen.getFirst().requestedBy());
|
||||
assertEquals(JSON.readTree("{\"setpoint\":\"cwu_economy\",\"value\":48,\"minutes\":60}"),
|
||||
JSON.readTree(commandsSeen.getFirst().body()));
|
||||
} finally {
|
||||
custom.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
|
||||
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(2), null, null), CLOCK);
|
||||
noToken.start();
|
||||
try {
|
||||
var base = "http://127.0.0.1:" + noToken.port();
|
||||
assertEquals(503, client.send(HttpRequest.newBuilder(URI.create(base + "/api/setpoints")).build(),
|
||||
HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
assertEquals(200, client.send(HttpRequest.newBuilder(URI.create(base + "/api/heat-pump")).build(),
|
||||
HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
assertTrue(commandsSeen.isEmpty());
|
||||
} finally {
|
||||
noToken.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -163,6 +253,14 @@ class BackendTest {
|
||||
assertTrue(e.getMessage().contains("INFLUX_URL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void urlsMayEndWithASingleSlash() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/"));
|
||||
|
||||
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsHaveDefaults() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
@@ -173,12 +271,33 @@ class BackendTest {
|
||||
assertEquals(Duration.ofSeconds(10), settings.influxTimeout());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenAndPresetsFileAreOptional() {
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
|
||||
var none = Settings.fromEnvironment(urls);
|
||||
var env = new HashMap<>(urls);
|
||||
env.put("GATEWAY_TOKEN", " s3cret-token ");
|
||||
env.put("PRESETS_FILE", "/etc/home-pump/presets.json");
|
||||
var set = Settings.fromEnvironment(env);
|
||||
env.put("GATEWAY_TOKEN", " ");
|
||||
env.put("PRESETS_FILE", "");
|
||||
|
||||
assertEquals(null, none.gatewayToken());
|
||||
assertEquals(null, none.presetsFile());
|
||||
assertEquals("s3cret-token", set.gatewayToken());
|
||||
assertEquals(Path.of("/etc/home-pump/presets.json"), set.presetsFile());
|
||||
assertEquals(null, Settings.fromEnvironment(env).gatewayToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsRejectBadValues() {
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
|
||||
for (Map<String, String> bad : List.of(
|
||||
Map.of("GATEWAY_URL", "gw:8080", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "ftp://gw", "INFLUX_URL", "https://influx.example"))) {
|
||||
Map.of("GATEWAY_URL", "ftp://gw", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "http://gw:8080/api", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example/influx/"),
|
||||
Map.of("GATEWAY_URL", "http://gw:8080?x=1", "INFLUX_URL", "https://influx.example"))) {
|
||||
assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(bad));
|
||||
}
|
||||
for (String name : List.of("PORT", "GATEWAY_TIMEOUT", "INFLUX_TIMEOUT")) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package home.backend.adapters.config;
|
||||
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
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 PresetFileTest {
|
||||
|
||||
@TempDir
|
||||
Path dir;
|
||||
|
||||
private Path file(String json) throws IOException {
|
||||
return Files.writeString(dir.resolve("presets.json"), json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsAreBoostAndAway() {
|
||||
var presets = PresetFile.load(null);
|
||||
|
||||
assertEquals(List.of(
|
||||
new Preset("cwu-now", "Ciepła woda teraz", null, List.of(new Change("cwu_economy", 45, 2.0))),
|
||||
new Preset("away", "Wyjazd", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 15, null)))), presets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileReplacesTheDefaults() throws Exception {
|
||||
var presets = PresetFile.load(file("""
|
||||
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
|
||||
"""));
|
||||
|
||||
assertEquals(List.of(new Preset("bath", "Kąpiel", null, List.of(new Change("cwu_economy", 48, 1.5)))), presets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unusableFilesAreRefusedWithTheirName() throws Exception {
|
||||
for (String json : List.of(
|
||||
"not json",
|
||||
"{}",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": []}]",
|
||||
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"tomorrow\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"chosen\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 2}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
|
||||
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
|
||||
Path file = file(json);
|
||||
var e = assertThrows(IllegalArgumentException.class, () -> PresetFile.load(file), json);
|
||||
assertTrue(e.getMessage().contains("PRESETS_FILE"), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFileIsRefused() {
|
||||
assertThrows(IllegalArgumentException.class, () -> PresetFile.load(dir.resolve("absent.json")));
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,20 @@ class GatewayHttpClientTest {
|
||||
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
|
||||
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
|
||||
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.RefreshResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
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.Instant;
|
||||
import java.time.LocalTime;
|
||||
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;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Against a stub gateway on localhost; the bodies are the gateway's own examples (the contract). */
|
||||
class GatewayHttpCommandsTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Path CONTRACT = Path.of("../gateway/src/test/resources/commands");
|
||||
|
||||
private record Seen(String method, String path, String authorization, String requestedBy, String body) {
|
||||
}
|
||||
|
||||
private final List<Seen> seen = new ArrayList<>();
|
||||
private HttpServer server;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
answerCommands();
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private int status;
|
||||
private String body = "{}";
|
||||
|
||||
private void serve(int status, String body) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
private void answerCommands() {
|
||||
for (String path : List.of("/commands", "/battery", "/refresh")) {
|
||||
server.createContext(path, this::answer);
|
||||
}
|
||||
}
|
||||
|
||||
private void answer(HttpExchange exchange) throws IOException {
|
||||
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
|
||||
exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
private GatewayHttpCommands commands(String token) {
|
||||
return new GatewayHttpCommands(URI.create("http://127.0.0.1:" + server.getAddress().getPort()), token,
|
||||
Duration.ofMillis(500));
|
||||
}
|
||||
|
||||
private static String contract(String name) throws IOException {
|
||||
return Files.readString(CONTRACT.resolve(name));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsTheBoostWithTokenUserAndMinutes() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
var result = commands("s3cret-token").apply(new Command("cwu_economy", 45, Duration.ofHours(2), "anna"));
|
||||
|
||||
assertEquals(Status.APPLIED, result.status());
|
||||
assertEquals(Instant.parse("2026-10-24T12:00:00Z"), result.restoreAt());
|
||||
assertEquals(1, seen.size());
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals("anna", seen.getFirst().requestedBy());
|
||||
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldChangeSendsTheEndAsUntil() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"), "phone"));
|
||||
|
||||
assertEquals(contractJson("request-held.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayHasNoMinutes() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, "phone"));
|
||||
|
||||
assertEquals(contractJson("request-away.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectionIsAVerdictNotAnError() throws Exception {
|
||||
serve(422, contract("response-rejected.json"));
|
||||
|
||||
var result = commands("s3cret-token").apply(new Command("cwu_economy", 60, null, "phone"));
|
||||
|
||||
assertEquals(Status.REJECTED, result.status());
|
||||
assertEquals("cwu_economy allows 5–48 in steps of 1, not 60", result.message());
|
||||
assertNull(result.restoreAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWriteIsAVerdict() throws Exception {
|
||||
serve(502, "{\"status\":\"FAILED\",\"message\":\"read back 8, expected 45\",\"restoreAt\":null}");
|
||||
|
||||
assertEquals(Status.FAILED, commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")).status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsSetpointsWithRestores() throws Exception {
|
||||
serve(200, contract("setpoints.json"));
|
||||
|
||||
var state = commands("s3cret-token").setpoints();
|
||||
|
||||
assertEquals("GET", seen.getFirst().method());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals(4, state.setpoints().size());
|
||||
assertEquals(48, state.setpoints().get(2).max());
|
||||
assertEquals(1, state.restores().size());
|
||||
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
|
||||
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
|
||||
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
|
||||
+ "\"value\":null}],\"restores\":[]}");
|
||||
|
||||
assertNull(commands("s3cret-token").setpoints().setpoints().getFirst().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherStatusesAreNoVerdict() throws Exception {
|
||||
for (int status : List.of(401, 404, 500, 503)) {
|
||||
serve(status, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").setpoints(), "GET " + status);
|
||||
assertThrows(IOException.class,
|
||||
() -> commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")), "POST " + status);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreadableAnswerIsNoVerdict() {
|
||||
serve(200, "<html>proxy</html>");
|
||||
|
||||
assertThrows(IOException.class,
|
||||
() -> commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutTokenNothingIsSent() {
|
||||
serve(200, "{}");
|
||||
var commands = commands(null);
|
||||
|
||||
assertThrows(IOException.class, () -> commands.setpoints());
|
||||
assertThrows(IOException.class, () -> commands.apply(new Command("cwu_economy", 45, null, "phone")));
|
||||
assertTrue(seen.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayIsAnIOException() {
|
||||
server.stop(0);
|
||||
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").setpoints());
|
||||
}
|
||||
|
||||
/** A contract file by name, or JSON text as is, parsed so that formatting does not matter. */
|
||||
private static JsonNode contractJson(String nameOrJson) throws IOException {
|
||||
return JSON.readTree(nameOrJson.endsWith(".json") ? contract(nameOrJson) : nameOrJson);
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,42 @@ package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.AuditRepository;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.application.GatewayClient;
|
||||
import home.backend.application.GatewayBattery;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView.Reserve;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.RefreshResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import home.backend.domain.Restore;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.application.HeatPumpSamples;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.WeatherService;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -26,12 +53,17 @@ import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ApiServerTest {
|
||||
@@ -43,6 +75,19 @@ class ApiServerTest {
|
||||
private GatewaySnapshot snapshot;
|
||||
private boolean gatewayDown;
|
||||
private boolean historyDown;
|
||||
private boolean auditDown;
|
||||
private HeatPumpSamples samples;
|
||||
private RuntimeException gatewayBug;
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
private Status verdict;
|
||||
private Outcome restoreOutcome;
|
||||
private int refreshes;
|
||||
private final List<String> restored = new ArrayList<>();
|
||||
private boolean commandsDown;
|
||||
private boolean batteryDown;
|
||||
private BatteryResult.Status batteryVerdict;
|
||||
private final List<String> batterySent = new ArrayList<>();
|
||||
private int answersBeforeDrop;
|
||||
private ApiServer server;
|
||||
|
||||
@BeforeEach
|
||||
@@ -52,6 +97,9 @@ class ApiServerTest {
|
||||
if (gatewayDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
if (gatewayBug != null) {
|
||||
throw gatewayBug;
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
HistoryRepository history = (series, from, to, max) -> {
|
||||
@@ -62,9 +110,111 @@ class ApiServerTest {
|
||||
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
|
||||
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
|
||||
};
|
||||
samples = (from, to) -> {
|
||||
if (historyDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return List.of(
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 0.0, 8.0, 44.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 0.0, 8.5, 43.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 100.0, 9.0, 45.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 0.0, 9.0, 46.0));
|
||||
};
|
||||
verdict = Status.APPLIED;
|
||||
restoreOutcome = Outcome.RESTORED;
|
||||
answersBeforeDrop = Integer.MAX_VALUE;
|
||||
GatewayCommands commands = new GatewayCommands() {
|
||||
@Override
|
||||
public CommandResult apply(Command command) throws IOException {
|
||||
if (commandsDown || sent.size() >= answersBeforeDrop) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
sent.add(command);
|
||||
return switch (verdict) {
|
||||
case APPLIED -> new CommandResult(verdict, "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z",
|
||||
command.duration() == null ? null : Instant.parse("2026-10-24T12:00:00Z"));
|
||||
case REJECTED -> new CommandResult(verdict, "cwu_economy allows 5–48 in steps of 1, not 60", null);
|
||||
case FAILED -> new CommandResult(verdict, "read back 8, expected 45", null);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
if (commandsDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
restored.add(setpoint + " by " + requestedBy);
|
||||
return new RestoreResult(restoreOutcome, "restored to 8");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (commandsDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
return new SetpointState(List.of(new Setpoint("co_economy", 4131, 5, 25, 1, 18),
|
||||
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
|
||||
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
|
||||
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"))));
|
||||
}
|
||||
|
||||
@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);
|
||||
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 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)));
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@@ -73,6 +223,27 @@ class ApiServerTest {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
private AuditRepository auditRows() {
|
||||
return new AuditRepository() {
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) throws IOException {
|
||||
if (auditDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return List.of(
|
||||
new Row(Instant.parse("2026-10-24T08:00:00Z"), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
|
||||
new Row(Instant.parse("2026-10-23T07:00:00Z"), "co_comfort", "mariusz", "REJECTED", null, 22.0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) {
|
||||
return List.of(
|
||||
new Row(Instant.parse("2026-10-24T09:30:00Z"), "reserve", "mariusz", "APPLIED", 50.0, 60.0),
|
||||
new Row(Instant.parse("2026-10-23T18:00:00Z"), "174", "mariusz", "FAILED", 0.0, 1.0));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static GatewaySnapshot exampleSnapshot() {
|
||||
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
|
||||
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
|
||||
@@ -84,18 +255,46 @@ class ApiServerTest {
|
||||
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
|
||||
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
|
||||
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
|
||||
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||
Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
|
||||
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
|
||||
"load_today_kwh", 24.2);
|
||||
return new GatewaySnapshot(List.of(
|
||||
"load_today_kwh", 24.2));
|
||||
deye.putAll(Map.of("battery_voltage_v", 56.49, "battery_current_a", -0.05, "battery_temperature_c", 19.2,
|
||||
"battery_charge_today_kwh", 0.5, "battery_discharge_today_kwh", 0.1,
|
||||
"battery_charge_total_kwh", 453.7, "battery_discharge_total_kwh", 162.1));
|
||||
return new GatewaySnapshot("v1.2.1", List.of(
|
||||
new DeviceSnapshot("heat-pump", true, pumpTime, null,
|
||||
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
|
||||
new DeviceSnapshot("deye", false, null, "connect timed out",
|
||||
new DeviceSnapshot("deye", true, energyTime, null,
|
||||
List.of(new Measurement("home", "deye", deye, energyTime))),
|
||||
new DeviceSnapshot("fronius", true, energyTime, null,
|
||||
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 {
|
||||
@@ -104,6 +303,10 @@ class ApiServerTest {
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static String awayRequest() throws IOException {
|
||||
return example("preset-away-request").toString();
|
||||
}
|
||||
|
||||
private static JsonNode example(String name) throws IOException {
|
||||
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
|
||||
return JSON.readTree(in);
|
||||
@@ -129,27 +332,230 @@ class ApiServerTest {
|
||||
assertMatchesExample("/api/energy", "energy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyDetailsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/energy/details", "energy-details");
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthMatchesExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot(snapshot.devices().subList(0, 2));
|
||||
snapshot = new GatewaySnapshot("v1.2.1", List.of(snapshot.devices().get(0),
|
||||
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
|
||||
assertMatchesExample("/api/health", "health");
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot("v1.2.1", List.of());
|
||||
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeyeMatchesExample() throws Exception {
|
||||
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||
Measurement deye = devices.get(1).measurements().get(0);
|
||||
devices.set(1, new DeviceSnapshot("deye", true, old, null,
|
||||
List.of(new Measurement("home", "deye", deye.fields(), old))));
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/energy", "energy-deye-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/battery", "battery");
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryWithStaleSettingsMatchesExample() throws Exception {
|
||||
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||
devices.set(devices.size() - 2, new DeviceSnapshot("deye-settings", true, old, null,
|
||||
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), old))));
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/battery", "battery-settings-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryWithoutGatewayCommandsIsNotEditable() throws Exception {
|
||||
batteryDown = true;
|
||||
|
||||
var body = JSON.readTree(get("/api/battery").body());
|
||||
|
||||
assertTrue(body.get("reserve").isNull());
|
||||
assertEquals(false, body.get("editable").asBoolean());
|
||||
assertEquals(99.0, body.get("socPct").asDouble());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
|
||||
var response = post("/api/battery/schedule", example("battery-schedule-request").toString(),
|
||||
"Remote-User", "anna");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
|
||||
new BatteryChange.Slot(2, java.time.LocalTime.of(9, 30), 90, true),
|
||||
new BatteryChange.Slot(3, null, 85, null))) + " by anna"), batterySent);
|
||||
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
|
||||
JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reserveChangeGoesToTheGateway() throws Exception {
|
||||
var response = post("/api/battery/reserve", example("battery-reserve-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(List.of("reserve 60 by phone"), batterySent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryStatusFollowsTheGatewaysVerdict() throws Exception {
|
||||
var expected = Map.of(BatteryResult.Status.REJECTED, 422, BatteryResult.Status.STALE, 409,
|
||||
BatteryResult.Status.TOO_SOON, 429, BatteryResult.Status.FAILED, 502);
|
||||
for (var entry : expected.entrySet()) {
|
||||
batteryVerdict = entry.getKey();
|
||||
|
||||
assertEquals(entry.getValue(), post("/api/battery/reserve", "{\"pct\":60}").statusCode());
|
||||
assertEquals(entry.getValue(), post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
|
||||
}
|
||||
batteryVerdict = BatteryResult.Status.FAILED;
|
||||
assertEquals(example("battery-failed"),
|
||||
JSON.readTree(post("/api/battery/schedule", "{\"enabled\":false}").body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryChangeWithTheGatewayDownIs503() throws Exception {
|
||||
batteryDown = true;
|
||||
|
||||
assertEquals(503, post("/api/battery/reserve", "{\"pct\":60}").statusCode());
|
||||
assertEquals(503, post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badBatteryRequestsAre400AndSendNothing() throws Exception {
|
||||
for (String body : List.of("", "not json", "[]", "{}", "{\"pct\":\"60\"}", "{\"pct\":60.5}", "{\"pct\":null}",
|
||||
"{\"pct\":60,\"x\":1}")) {
|
||||
assertEquals(400, post("/api/battery/reserve", body).statusCode(), body);
|
||||
}
|
||||
for (String body : List.of("", "not json", "[]", "{\"enabled\":\"yes\"}", "{\"slots\":[{\"slot\":1,\"from\":\"9h\"}]}",
|
||||
"{\"slots\":[{\"slot\":1,\"from\":\"25:00\"}]}", "{\"slots\":[{\"slot\":1,\"socPct\":\"9\"}]}",
|
||||
"{\"slots\":[{\"slot\":1,\"soc\":9}]}", "{\"slots\":[{\"slot\":1,\"gridCharge\":1}]}")) {
|
||||
assertEquals(400, post("/api/battery/schedule", body).statusCode(), body);
|
||||
}
|
||||
assertTrue(batterySent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryWritesAreGuardedLikeEveryWrite() throws Exception {
|
||||
for (String path : List.of("/api/battery/reserve", "/api/battery/schedule")) {
|
||||
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode(), path);
|
||||
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode(), path);
|
||||
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode(), path);
|
||||
assertEquals(405, get(path).statusCode(), path);
|
||||
}
|
||||
assertEquals(404, post("/api/battery/other", "{}").statusCode());
|
||||
assertTrue(batterySent.isEmpty());
|
||||
}
|
||||
|
||||
private static Forecast exampleForecast() {
|
||||
Map<LocalDate, Forecast.DailyForecast> days = new TreeMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
days.put(LocalDate.parse("2026-10-19").plusDays(i), new Forecast.DailyForecast(3, 12.0, 4.0, 0.0, 10.0, 7200.0));
|
||||
}
|
||||
days.put(LocalDate.parse("2026-10-24"), new Forecast.DailyForecast(3, 10.9, 3.8, 0.0, 9.0, 7151.0));
|
||||
days.put(LocalDate.parse("2026-10-25"), new Forecast.DailyForecast(51, 13.2, 4.0, 0.3, 4.5, 28314.0));
|
||||
days.put(LocalDate.parse("2026-10-26"), new Forecast.DailyForecast(61, 9.6, 4.1, 8.4, 2.1, 0.0));
|
||||
return new Forecast(10.6, days);
|
||||
}
|
||||
|
||||
/** Twice the forecast radiation of the same days, so the factor is 2 kWh per MJ/m². */
|
||||
private static Map<LocalDate, Double> exampleMeasuredPv() {
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
measured.put(LocalDate.parse("2026-10-19").plusDays(i), 20.0);
|
||||
}
|
||||
return measured;
|
||||
}
|
||||
|
||||
private static Warning exampleWarning() {
|
||||
return new Warning("Silny wiatr", 2, OffsetDateTime.parse("2026-10-25T08:00:00+01:00"),
|
||||
OffsetDateTime.parse("2026-10-25T18:00:00+01:00"),
|
||||
"Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void weatherMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/weather", "weather");
|
||||
}
|
||||
|
||||
@Test
|
||||
void piSensorMatchesExample()throws Exception {
|
||||
assertMatchesExample("/api/pi-sensor", "pi-sensor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stalePiSensorMatchesExample() throws Exception {
|
||||
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||
Measurement bme = devices.getLast().measurements().get(0);
|
||||
devices.set(devices.size() - 1, new DeviceSnapshot("bme280", true, old, null,
|
||||
List.of(new Measurement(bme.database(), bme.measurement(), bme.fields(), old))));
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/pi-sensor", "pi-sensor-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/stats?range=7d", "stats");
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsNeedAKnownRange() throws Exception {
|
||||
assertEquals(400, get("/api/stats").statusCode());
|
||||
assertEquals(400, get("/api/stats?range=24h").statusCode());
|
||||
assertEquals(200, get("/api/stats?range=30d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsAre502WhenInfluxIsDown() throws Exception {
|
||||
historyDown = true;
|
||||
|
||||
assertEquals(502, get("/api/stats?range=7d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyRangeDefaultsTo24Hours() throws Exception {
|
||||
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/audit?range=7d", "audit");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditRangeDefaultsAndIsChecked() throws Exception {
|
||||
assertEquals(200, get("/api/audit").statusCode());
|
||||
assertEquals(400, get("/api/audit?range=90d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditInfluxFailureIs502() throws Exception {
|
||||
auditDown = true;
|
||||
|
||||
assertEquals(502, get("/api/audit?range=7d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayIs503WithJsonError() throws Exception {
|
||||
gatewayDown = true;
|
||||
|
||||
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);
|
||||
assertEquals(503, response.statusCode());
|
||||
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
|
||||
@@ -173,6 +579,26 @@ class ApiServerTest {
|
||||
assertEquals(400, get("/api/history?series=cwu_temp&range=30d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedQueryIs400NotADroppedConnection() throws Exception {
|
||||
for (String path : List.of("/api/history?series=%", "/api/history?series=%zz&range=24h")) {
|
||||
assertEquals(400, rawGet(path), path);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unusableGatewayPayloadIs502() throws Exception {
|
||||
for (RuntimeException bug : List.of(new IllegalStateException("Duplicate key deye"),
|
||||
new NullPointerException("status"))) {
|
||||
gatewayBug = bug;
|
||||
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/health")) {
|
||||
HttpResponse<String> response = get(path);
|
||||
assertEquals(502, response.statusCode(), path);
|
||||
assertEquals("bad gateway response", JSON.readTree(response.body()).get("error").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void influxFailureIs502() throws Exception {
|
||||
historyDown = true;
|
||||
@@ -180,6 +606,299 @@ class ApiServerTest {
|
||||
assertEquals(502, get("/api/history?series=cwu_temp&range=7d").statusCode());
|
||||
}
|
||||
|
||||
private static final String[] BROWSER_HEADERS = {"Content-Type", "application/json", "X-Requested-With", "home-pump"};
|
||||
|
||||
/** A POST as the web UI sends it; extra headers come after the defaults. */
|
||||
private HttpResponse<String> post(String path, String body, String... headers) throws Exception {
|
||||
var all = new ArrayList<>(List.of(BROWSER_HEADERS));
|
||||
all.addAll(List.of(headers));
|
||||
return rawPost(path, body, all.toArray(String[]::new));
|
||||
}
|
||||
|
||||
private HttpResponse<String> rawPost(String path, String body, String... headers) throws Exception {
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
if (headers.length > 0) {
|
||||
request.headers(headers);
|
||||
}
|
||||
return client.send(request.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static final String BOOST = "{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":2}";
|
||||
|
||||
@Test
|
||||
void setpointsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/setpoints", "setpoints");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliedBoostMatchesExampleAndRestoresAfterTwoHours() throws Exception {
|
||||
HttpResponse<String> response = post("/api/setpoints", BOOST);
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("setpoints-applied"), JSON.readTree(response.body()));
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
|
||||
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
|
||||
assertEquals(List.of("cwu_economy by anna"), restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreStatusFollowsTheGatewaysVerdict() throws Exception {
|
||||
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
|
||||
Outcome.FAILED, 502);
|
||||
for (var entry : expected.entrySet()) {
|
||||
restoreOutcome = entry.getKey();
|
||||
|
||||
assertEquals(entry.getValue(), post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreWithTheGatewayDownIs503() throws Exception {
|
||||
commandsDown = true;
|
||||
|
||||
assertEquals(503, post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreIsGuardedLikeEveryWrite() throws Exception {
|
||||
String path = "/api/setpoints/cwu_economy/restore";
|
||||
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
|
||||
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
|
||||
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
|
||||
assertEquals(405, get(path).statusCode());
|
||||
assertTrue(restored.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 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
|
||||
void rejectedIs422AndMatchesExample() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
|
||||
HttpResponse<String> response = post("/api/setpoints", BOOST);
|
||||
|
||||
assertEquals(422, response.statusCode());
|
||||
assertEquals(example("setpoints-rejected"), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWriteIs502() throws Exception {
|
||||
verdict = Status.FAILED;
|
||||
|
||||
assertEquals(502, post("/api/setpoints", BOOST).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception {
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl");
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
|
||||
post("/api/setpoints", BOOST);
|
||||
|
||||
assertEquals(List.of("anna.k@example.pl", "phone", "phone"), sent.stream().map(Command::requestedBy).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badSetpointRequestsAre400AndSendNothing() throws Exception {
|
||||
for (String body : List.of("", "not json", "[]", "{}", "{\"setpoint\":\"cwu_economy\"}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45.5}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":\"45\"}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":0}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":-1}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":72.5}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"minutes\":5}")) {
|
||||
assertEquals(400, post("/api/setpoints", body).statusCode(), body);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetResultsMatchExample() throws Exception {
|
||||
HttpResponse<String> response = post("/api/presets/away", awayRequest());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
JsonNode body = JSON.readTree(response.body());
|
||||
assertEquals(example("setpoints-preset-applied").get("results").size(), body.get("results").size());
|
||||
assertEquals("APPLIED", body.get("status").asText());
|
||||
assertEquals(List.of("cwu_comfort", "co_comfort"), sent.stream().map(Command::setpoint).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
|
||||
answersBeforeDrop = 1;
|
||||
|
||||
HttpResponse<String> response = post("/api/presets/away", awayRequest());
|
||||
|
||||
assertEquals(502, response.statusCode());
|
||||
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsTheChosenEndHeldInEachChange() throws Exception {
|
||||
post("/api/presets/away", awayRequest());
|
||||
|
||||
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, end, "phone"),
|
||||
new Command("co_comfort", 15, null, end, "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayWithOwnValuesSendsThem() throws Exception {
|
||||
var response = post("/api/presets/away", example("preset-away-values-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
|
||||
assertEquals(List.of(new Command("cwu_comfort", 12, null, end, "phone"),
|
||||
new Command("co_comfort", 16, null, end, "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValuesForOtherSetpointsOrOutsideTheLimitsAre400AndSendNothing() throws Exception {
|
||||
String until = "\"until\":\"2026-11-02T18:00:00Z\"";
|
||||
for (String values : List.of("{\"cwu_economy\":6}", "{\"co_comfort\":4}", "{\"co_comfort\":26}",
|
||||
"{\"co_comfort\":null}", "{\"co_comfort\":15.5}", "{\"co_comfort\":\"15\"}", "[]")) {
|
||||
assertEquals(400, post("/api/presets/away", "{" + until + ",\"values\":" + values + "}").statusCode(),
|
||||
values);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValueThatDoesNotLowerAHeldSetpointIs422() throws Exception {
|
||||
var body = "{\"until\":\"2026-11-02T18:00:00Z\",\"values\":{\"co_comfort\":19}}";
|
||||
|
||||
assertEquals(422, post("/api/presets/away", body).statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badOrMissingEndTimesAre400AndSendNothing() throws Exception {
|
||||
for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}",
|
||||
"{\"until\":\"2026-11-02T18:00:00\"}", "not json", "{\"until\":\"2026-11-02T18:00:00Z\",\"x\":1}")) {
|
||||
assertEquals(400, post("/api/presets/away", body).statusCode(), body);
|
||||
}
|
||||
assertEquals(400, post("/api/presets/cwu-now", awayRequest()).statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetWithoutAnEndStillTakesNoBodyOrAnEmptyObject() throws Exception {
|
||||
assertEquals(200, post("/api/presets/cwu-now", "").statusCode());
|
||||
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIs404() throws Exception {
|
||||
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetOutsideLimitsIs422() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
|
||||
assertEquals(422, post("/api/presets/away", awayRequest()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayOnSetpointsIs503() throws Exception {
|
||||
commandsDown = true;
|
||||
|
||||
assertEquals(503, get("/api/setpoints").statusCode());
|
||||
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
|
||||
assertEquals(503, post("/api/presets/away", awayRequest()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesWithoutJsonContentTypeAre415() throws Exception {
|
||||
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
|
||||
assertEquals(415, rawPost(path, BOOST, "X-Requested-With", "home-pump").statusCode(), path);
|
||||
assertEquals(415, rawPost(path, BOOST, "X-Requested-With", "home-pump", "Content-Type", "text/plain")
|
||||
.statusCode(), path);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonContentTypeMayCarryACharset() throws Exception {
|
||||
assertEquals(200, rawPost("/api/setpoints", BOOST, "X-Requested-With", "home-pump",
|
||||
"Content-Type", "application/json; charset=utf-8").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesWithoutTheCustomHeaderAre403() throws Exception {
|
||||
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
|
||||
assertEquals(403, rawPost(path, BOOST, "Content-Type", "application/json").statusCode(), path);
|
||||
assertEquals(403, rawPost(path, BOOST, "Content-Type", "application/json",
|
||||
"X-Requested-With", "XMLHttpRequest").statusCode(), path);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesFromAnotherOriginAre403() throws Exception {
|
||||
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
|
||||
for (String origin : List.of("https://evil.example", "null", "http://localhost:1")) {
|
||||
assertEquals(403, post(path, BOOST, "Origin", origin).statusCode(), origin);
|
||||
}
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesFromTheSameOriginAreAccepted() throws Exception {
|
||||
assertEquals(200, post("/api/setpoints", BOOST, "Origin", "http://localhost:" + server.port()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setpointMethodsAreChecked() throws Exception {
|
||||
assertEquals(405, post("/api/history", "").statusCode());
|
||||
assertEquals(405, get("/api/presets/away").statusCode());
|
||||
HttpRequest put = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/setpoints"))
|
||||
.PUT(HttpRequest.BodyPublishers.noBody()).build();
|
||||
assertEquals(405, client.send(put, HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiAcceptsOnlyGet() throws Exception {
|
||||
HttpRequest post = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/health"))
|
||||
@@ -188,50 +907,64 @@ class ApiServerTest {
|
||||
assertEquals(405, client.send(post, HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyResponseCarriesTheSecurityHeaders() throws Exception {
|
||||
var paths = List.of("/", "/index.html", "/missing.css", "/api/health", "/api/nothing", "/api/history");
|
||||
for (String path : paths) {
|
||||
assertSecurityHeaders(get(path), path);
|
||||
}
|
||||
assertSecurityHeaders(post("/api/health", ""), "405");
|
||||
assertSecurityHeaders(post("/api/setpoints", "{"), "400");
|
||||
assertSecurityHeaders(rawPost("/api/setpoints", BOOST), "403");
|
||||
}
|
||||
|
||||
private static void assertSecurityHeaders(HttpResponse<String> response, String what) {
|
||||
var headers = response.headers();
|
||||
assertEquals("default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
|
||||
+ "font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
|
||||
headers.firstValue("Content-Security-Policy").orElse(null), what);
|
||||
assertEquals("nosniff", headers.firstValue("X-Content-Type-Options").orElse(null), what);
|
||||
assertEquals("no-referrer", headers.firstValue("Referrer-Policy").orElse(null), what);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownApiPathIs404() throws Exception {
|
||||
assertEquals(404, get("/api/nothing").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheRealIndexAtRoot() throws Exception {
|
||||
HttpResponse<String> response = get("/");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("text/html; charset=utf-8", response.headers().firstValue("Content-Type").orElseThrow());
|
||||
assertTrue(response.body().contains("Dom Krosno"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesStaticFilesWithContentTypes() throws Exception {
|
||||
Map<String, String> types = Map.of(
|
||||
"/index.html", "text/html; charset=utf-8",
|
||||
"/test.css", "text/css; charset=utf-8",
|
||||
"/test.js", "text/javascript; charset=utf-8",
|
||||
"/test.svg", "image/svg+xml",
|
||||
"/test.png", "image/png",
|
||||
"/test.ico", "image/x-icon");
|
||||
|
||||
for (Map.Entry<String, String> entry : types.entrySet()) {
|
||||
HttpResponse<String> response = get(entry.getKey());
|
||||
assertEquals(200, response.statusCode(), entry.getKey());
|
||||
assertEquals(entry.getValue(), response.headers().firstValue("Content-Type").orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingUnknownTypeAndDirectoriesAre404() throws Exception {
|
||||
for (String path : List.of("/missing.css", "/sub/data.txt", "/sub", "/sub/", "/web")) {
|
||||
void apiRoutesMatchTheExactPathOnly() throws Exception {
|
||||
for (String path : List.of("/api/heat-pumpX", "/api/heat-pump/", "/api/heat-pump/x", "/api/energy2",
|
||||
"/api/healthz", "/api/history2?series=cwu_temp", "/api/setpointsX")) {
|
||||
assertEquals(404, get(path).statusCode(), path);
|
||||
}
|
||||
assertEquals(404, post("/api/setpointsX", BOOST).statusCode());
|
||||
assertEquals(200, get("/api/heat-pump?x=1").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathTraversalIsRefused() throws Exception {
|
||||
// secret.css sits next to web/ on the test classpath
|
||||
for (String path : List.of("/../secret.css", "/%2e%2e/secret.css", "/sub/../../secret.css",
|
||||
"/%2e%2e%2fsecret.css", "/..%5csecret.css")) {
|
||||
assertEquals(404, rawGet(path), path);
|
||||
void stopClosesThePort() {
|
||||
server.stop();
|
||||
|
||||
assertThrows(IOException.class, () -> get("/api/health"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestBodiesOver4KbAre413AndSendNothing() throws Exception {
|
||||
String padding = " ".repeat(4096 - BOOST.length());
|
||||
assertEquals(200, post("/api/setpoints", BOOST + padding).statusCode());
|
||||
sent.clear();
|
||||
|
||||
assertEquals(413, post("/api/setpoints", BOOST + padding + " ").statusCode());
|
||||
assertEquals(413, post("/api/presets/away", "x".repeat(5000)).statusCode());
|
||||
assertEquals(0, sent.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void answersOnlyTheApi() throws Exception {
|
||||
// The web UI is the frontend container's (ADR-010); the backend serves no files.
|
||||
for (String path : List.of("/", "/index.html", "/assets/index.js", "/api", "/api/")) {
|
||||
assertEquals(404, get(path).statusCode(), path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+68
-2
@@ -1,6 +1,7 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -18,9 +19,14 @@ import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -33,22 +39,36 @@ class InfluxHistoryRepositoryTest {
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(5);
|
||||
|
||||
private HttpServer server;
|
||||
private final List<Map<String, String>> requests = new ArrayList<>();
|
||||
private final List<Map<String, String>> requests = Collections.synchronizedList(new ArrayList<>());
|
||||
private volatile CountDownLatch rendezvous = new CountDownLatch(0);
|
||||
private final AtomicInteger metTogether = new AtomicInteger();
|
||||
private final Map<String, String> bodyByField = new HashMap<>();
|
||||
private int status = 200;
|
||||
private String failingField = "";
|
||||
private String defaultBody = "{\"results\":[{\"statement_id\":0}]}";
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
// Concurrent handlers: the repository asks for the parts of a series at the same time.
|
||||
server.setExecutor(Executors.newCachedThreadPool());
|
||||
server.createContext("/query", exchange -> {
|
||||
Map<String, String> params = params(exchange.getRequestURI().getRawQuery());
|
||||
requests.add(params);
|
||||
rendezvous.countDown();
|
||||
try {
|
||||
if (rendezvous.await(5, TimeUnit.SECONDS)) {
|
||||
metTogether.incrementAndGet();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
String body = bodyByField.entrySet().stream()
|
||||
.filter(e -> params.get("q").contains("\"" + e.getKey() + "\""))
|
||||
.map(Map.Entry::getValue).findFirst().orElse(defaultBody);
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
boolean fails = !failingField.isEmpty() && params.get("q").contains("\"" + failingField + "\"");
|
||||
exchange.sendResponseHeaders(fails ? 500 : status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
@@ -106,6 +126,23 @@ class InfluxHistoryRepositoryTest {
|
||||
new Point(Instant.ofEpochMilli(3000), 7.0)), points);
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksForTheThreePvPartsAtTheSameTime() throws IOException {
|
||||
rendezvous = new CountDownLatch(3);
|
||||
|
||||
repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72);
|
||||
|
||||
// A handler passes the latch only when all three requests are in flight together.
|
||||
assertEquals(3, metTogether.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneFailingPartFailsTheWholeSeries() {
|
||||
failingField = "pv2_power_w";
|
||||
|
||||
assertThrows(IOException.class, () -> repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sortsPointsByTime() throws IOException {
|
||||
defaultBody = series(3000, 3.0, 1000, 1.0, 2000, 2.0);
|
||||
@@ -172,6 +209,35 @@ class InfluxHistoryRepositoryTest {
|
||||
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsRawHeatPumpPollsFromTheLegacyDatabase() throws IOException {
|
||||
defaultBody = fixture("influx-query-heat-pump.json");
|
||||
|
||||
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
|
||||
|
||||
assertEquals("Dom_Pompa", requests.get(0).get("db"));
|
||||
assertEquals("SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
|
||||
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
|
||||
requests.get(0).get("q"));
|
||||
assertEquals(3, samples.size());
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 0.0, 8.5, 43.9),
|
||||
samples.get(1));
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null),
|
||||
samples.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noHeatPumpPollsIsAnEmptyList() throws IOException {
|
||||
assertTrue(repository(TIMEOUT).samples(FROM, TO).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpQueryErrorIsAnIoException() throws IOException {
|
||||
defaultBody = fixture("influx-query-error.json");
|
||||
|
||||
assertThrows(IOException.class, () -> repository(TIMEOUT).samples(FROM, TO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnEmptyRange() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
|
||||
Loaded 100 of 424 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user