Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6480c98647 | ||
|
|
31e9d056aa | ||
|
|
326db85114 | ||
|
|
ead74ebdec | ||
|
|
f99ae01626 | ||
|
|
a804923290 | ||
|
|
cc0495fc46 | ||
|
|
b3fb7f8b40 | ||
|
|
33aead7352 | ||
|
|
ee13c00bbb | ||
|
|
2e94cc1429 | ||
|
|
0895125e9c | ||
|
|
36bee5ca08 | ||
|
|
fa678473e3 | ||
|
|
b46d820fb2 | ||
|
|
f61c25f449 | ||
|
|
eec29666eb | ||
|
|
6d9e92ee22 | ||
|
|
bb22afcd35 | ||
|
|
ae9376b24d | ||
|
|
31b668f313 | ||
|
|
36875dd103 | ||
|
|
c8ada0e6a8 | ||
|
|
0e3a77047b | ||
|
|
93fedb198c | ||
|
|
b69beadd76 | ||
|
|
62d61af5dd | ||
|
|
4e8ffc2ec3 | ||
|
|
eac21b17e2 | ||
|
|
73bbb1a0d7 | ||
|
|
f92307d9e1 | ||
|
|
34b12881b9 | ||
|
|
b7b26d83b2 | ||
|
|
24b7946df1 | ||
|
|
16534ddcee | ||
|
|
e6d9487f0c | ||
|
|
64ffd035c5 | ||
|
|
87d111398c | ||
|
|
24a141a377 | ||
|
|
a1d6a5c8a6 | ||
|
|
d1f053079a | ||
|
|
8b07b9ba0e | ||
|
|
aaf08d4f5c | ||
|
|
b0c0872775 | ||
|
|
f128b03ae5 | ||
|
|
9f9e4e4f50 | ||
|
|
4b26028d01 | ||
|
|
655e84be8d | ||
|
|
4cb8bfd27b | ||
|
|
bdea2a177d | ||
|
|
d8b06e2a89 | ||
|
|
1405695da5 | ||
|
|
ea2862ae04 | ||
|
|
317950cb75 | ||
|
|
8423d723a1 | ||
|
|
cb93f24365 | ||
|
|
f181d0bbe3 | ||
|
|
f57c3adc58 | ||
|
|
56a457085e | ||
|
|
6dcbb5b8e4 | ||
|
|
ace189f5e2 | ||
|
|
d356fb2a2c | ||
|
|
0fb139192d | ||
|
|
40823683be | ||
|
|
1f43987db5 | ||
|
|
e88c28899f | ||
|
|
d5ab64aa61 | ||
|
|
01c5e1efe5 | ||
|
|
0819ed8261 | ||
|
|
338befaffa | ||
|
|
378f4f6623 | ||
|
|
82670eb4a1 | ||
|
|
d07be384a1 | ||
|
|
860269a353 | ||
|
|
e4481eeeae | ||
|
|
e1e50eb7c2 | ||
|
|
c7d88e6c0b | ||
|
|
186c1984c0 | ||
|
|
83d9e1ecfc | ||
|
|
cbf507fa18 | ||
|
|
a99f2b677a | ||
|
|
928915d059 | ||
|
|
c0c46973e8 | ||
|
|
64e1e6ffec | ||
|
|
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 | ||
|
|
2343fe410f | ||
|
|
e2f3b58777 | ||
|
|
322f4834a8 | ||
|
|
d8a9716192 | ||
|
|
1cc8a3b545 | ||
|
|
9ec0ae2425 | ||
|
|
f84bf5a953 | ||
|
|
d6531054ec | ||
|
|
01fb331e22 | ||
|
|
15a3fc953e | ||
|
|
e591c125cc | ||
|
|
5b75c3b181 | ||
|
|
08650bd0f7 | ||
|
|
4446499996 | ||
|
|
3813e010ae | ||
|
|
72960e74d0 | ||
|
|
ceae74efd1 | ||
|
|
dd02947bb2 | ||
|
|
fb72010a12 | ||
|
|
860aaa00e0 | ||
|
|
a22788ef48 | ||
|
|
1c74044ad0 | ||
|
|
1f114367b6 | ||
|
|
cf75f55c6b | ||
|
|
7cf4576e49 | ||
|
|
a1e749e4ad | ||
|
|
1a8e8e8585 | ||
|
|
eb0b23637b | ||
|
|
0db35d06f8 | ||
|
|
5be8ce49ef | ||
|
|
f0cbcb00f5 | ||
|
|
e8aa40f93f | ||
|
|
cac625e0e9 | ||
|
|
a025bf8298 | ||
|
|
ae5f2536c8 | ||
|
|
f71d112df7 | ||
|
|
a713147a30 | ||
|
|
1f9e245f2e | ||
|
|
da461bdf27 | ||
|
|
e90f28dbc3 | ||
|
|
d5328e40fc | ||
|
|
df96cb73d1 | ||
|
|
58ffc2e056 | ||
|
|
649d2e9cdd | ||
|
|
ae2e0ae896 | ||
|
|
4c4b2143ac | ||
|
|
996b6805a4 | ||
|
|
6c6f071a80 | ||
|
|
722b67b252 | ||
|
|
98fe849254 | ||
|
|
08f7bb8a8f | ||
|
|
3705a57155 | ||
|
|
3b15451874 | ||
|
|
4db8520303 | ||
|
|
e9a3c45d89 | ||
|
|
51a68f0d44 | ||
|
|
15c5b7a7af | ||
|
|
d2873efc7c | ||
|
|
3be02ed3a7 | ||
|
|
6a10fd5ccb | ||
|
|
4e92d16f41 | ||
|
|
5d3201f7e3 | ||
|
|
821f818b1e | ||
|
|
b9c401f077 | ||
|
|
f6f07c4225 | ||
|
|
2d61ce19f4 | ||
|
|
80873d748c | ||
|
|
df2d588e33 | ||
|
|
04a17041ec | ||
|
|
45ab01cdd0 | ||
|
|
797920bb82 | ||
|
|
77e3e0cb2a | ||
|
|
162e557334 | ||
|
|
2b06a5f344 | ||
|
|
ce95b7786a | ||
|
|
4669f430ec | ||
|
|
b328848b87 | ||
|
|
b99414d829 | ||
|
|
79c67f334c | ||
|
|
60f35b01a6 | ||
|
|
b0c4961cda | ||
|
|
7f2e98a781 | ||
|
|
1e43d9d7fb | ||
|
|
0a5d961113 | ||
|
|
6828049404 | ||
|
|
b224ffa9dc | ||
|
|
1cae89f634 | ||
|
|
5c1787de76 | ||
|
|
bfecae8b1f | ||
|
|
dc5efc07e1 | ||
|
|
585c1d4e3d | ||
|
|
b47bcf7a18 | ||
|
|
2eaddcc414 | ||
|
|
6a3dfdf651 | ||
|
|
c2c63918ab | ||
|
|
f7136c7ddb | ||
|
|
6a293ee15e | ||
|
|
408bba73ac | ||
|
|
c73059eef7 | ||
|
|
3839668348 | ||
|
|
0286c4c18d | ||
|
|
656b581812 | ||
|
|
162a25eceb | ||
|
|
225648db81 | ||
|
|
365d0b124a | ||
|
|
e5571548aa | ||
|
|
887806d329 | ||
|
|
607058e42b | ||
|
|
1676b46b33 | ||
|
|
9f795485ee | ||
|
|
e35dee9b4f | ||
|
|
4642408039 | ||
|
|
a59a5494ff | ||
|
|
7bbda72e59 | ||
|
|
26f7b30abe | ||
|
|
13bc0955f0 | ||
|
|
d230d86250 | ||
|
|
484186e2dc | ||
|
|
fe8257b96c | ||
|
|
2544421c72 | ||
|
|
17d326fd5f | ||
|
|
32424a118c | ||
|
|
c309916736 | ||
|
|
e345063720 | ||
|
|
ecc4f19076 | ||
|
|
2664e7e52f | ||
|
|
f2cb8d664d | ||
|
|
1b725ec9a7 | ||
|
|
254c09b771 | ||
|
|
da604c1945 | ||
|
|
c59c26bbe0 | ||
|
|
40a8602b46 | ||
|
|
6fab319fbb | ||
|
|
9d140a5880 | ||
|
|
4060e40d9e | ||
|
|
50527a363c | ||
|
|
399b6200fa | ||
|
|
0c224e16a0 | ||
|
|
f0b9546fdc | ||
|
|
d77f9e643e | ||
|
|
8065af3fec | ||
|
|
452a58c154 | ||
|
|
7398be4151 | ||
|
|
ac94218045 | ||
|
|
d07e2812d8 | ||
|
|
d902523b11 | ||
|
|
cc263f474c | ||
|
|
84608f14fe | ||
|
|
19afd28304 | ||
|
|
e3b060b3cc | ||
|
|
4ea9a9396d | ||
|
|
6ffbfc7848 | ||
|
|
b5f095b117 | ||
|
|
1c7e4fc751 | ||
|
|
9f4a2f2dc4 | ||
|
|
a7a9559494 | ||
|
|
0f538b60a9 | ||
|
|
176d62967d | ||
|
|
d334449e56 | ||
|
|
b187abc55f | ||
|
|
07bfd79e9a | ||
|
|
4a84ae041f | ||
|
|
46030746eb | ||
|
|
9f17db0959 | ||
|
|
8fea494dbd | ||
|
|
0322440476 | ||
|
|
c370d9e95e | ||
|
|
4fd78bbf08 | ||
|
|
d4cb077594 | ||
|
|
99d1687f86 | ||
|
|
ac0b9edf78 | ||
|
|
0db5d4931c | ||
|
|
1be34071bc | ||
|
|
828f1f8c0c | ||
|
|
7228906b34 | ||
|
|
eec2f2e990 | ||
|
|
291d81fd26 | ||
|
|
7ce15e1fcd | ||
|
|
0ad26402d0 | ||
|
|
5d01f9491f | ||
|
|
c4ccca5bf7 | ||
|
|
d43842a31a | ||
|
|
266b093114 | ||
|
|
5902e27182 | ||
|
|
6364a3e8e7 | ||
|
|
6b2a689b58 | ||
|
|
02b44a5ea0 | ||
|
|
5095c83986 | ||
|
|
f801e98554 | ||
|
|
af0139a799 | ||
|
|
f02529eadf | ||
|
|
962ee435ff | ||
|
|
ec4f58bc31 | ||
|
|
1e0ae84929 | ||
|
|
ab916501fc | ||
|
|
c7cbb3ea05 | ||
|
|
fbb976445e |
No files matched your search
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: close-milestone
|
||||
description: Close the current milestone in docs/roadmap.md, remove its working notes and start the next one. Use when every item of the current milestone is done.
|
||||
---
|
||||
|
||||
# Close milestone
|
||||
|
||||
1. Read `docs/roadmap.md`. Every item of "Current" is checked and its "Done when" holds.
|
||||
Anything you cannot verify yourself (e.g. "a week on the Pi") you ask the user about.
|
||||
Stop and report if something is open.
|
||||
2. Branch `docs/close-<milestone>` (e.g. `docs/close-m1`).
|
||||
3. Under "Done", add one line: `- M<n> <name> · <tag or commit>: <what it delivered>`.
|
||||
Remove the "Current" checklist and any placeholder line.
|
||||
4. Remove the milestone's working notes: fixed defects (D…), consumed feasibility checks,
|
||||
notes that served only this milestone. Verified knowledge moves first: controller facts
|
||||
to `docs/frisko/`, scope or architecture to `docs/spec.md`, decisions to a new ADR.
|
||||
5. Make the next row of "Next" the new "Current": a checklist of PR-sized items in build
|
||||
order, keeping its "Done when". Ask the user when the split is not obvious.
|
||||
6. `scripts/check.sh` (roadmap ≤ 80 lines), then `/ship`.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: ship
|
||||
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
|
||||
|
||||
Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/home-pump
|
||||
(no API token here; reads need none).
|
||||
|
||||
1. On a topic branch (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), clean tree, commits
|
||||
in Conventional Commits. Run `scripts/check.sh`.
|
||||
2. `git push -u origin <branch>`; CI runs on every pushed branch.
|
||||
3. Open the PR by push (Gitea AGit), title in Conventional Commits, description says why:
|
||||
`git push origin HEAD:refs/for/master -o topic=<branch> -o title="…" -o description="…"`.
|
||||
The description is one line (push options cannot hold newlines). After later fixes push
|
||||
both the branch and `refs/for/master` with the same topic, so CI and the PR both update.
|
||||
If that is refused, give the user the compare link `…/compare/master...<branch>`.
|
||||
4. Wait for CI on the head commit, polling every 30 s:
|
||||
`curl -s …/api/v1/repos/mariusz.pacek/home-pump/commits/<sha>/status` → `state`.
|
||||
On `failure`, read the run under `…/actions`, fix, push again. Never merge red.
|
||||
5. Merge only if the user asked for it in this task:
|
||||
`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. 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).
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check (gateway, backend, frontend)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Java 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- name: Set up Maven
|
||||
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
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Enabled once per clone: git config core.hooksPath .githooks
|
||||
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
|
||||
+45
-1
@@ -4,9 +4,10 @@
|
||||
.settings/
|
||||
|
||||
# Intellij
|
||||
.idea/
|
||||
.idea
|
||||
*.iml
|
||||
*.iws
|
||||
.vscode
|
||||
|
||||
# Mac
|
||||
.DS_Store
|
||||
@@ -14,3 +15,46 @@
|
||||
# Maven
|
||||
log/
|
||||
target/
|
||||
/.idea/
|
||||
/.vscode/
|
||||
/.java-version
|
||||
dependency-reduced-pom.xml
|
||||
/workspace.code-workspace
|
||||
/target/
|
||||
|
||||
# Node / frontend
|
||||
/node_modules/
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/dist/
|
||||
|
||||
# Vite
|
||||
/.vite/
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Build / coverage
|
||||
build/
|
||||
coverage/
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
|
||||
# Large or legacy folders (not tracked)
|
||||
/apk/
|
||||
/old/
|
||||
/old_backup/
|
||||
|
||||
# Agent worktrees
|
||||
/.claude/worktrees/
|
||||
@@ -1,3 +0,0 @@
|
||||
# This file is a template, and might need editing before it works on your project.
|
||||
include:
|
||||
template: Jobs/Code-Quality.gitlab-ci.yml
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
Generated
-7
@@ -1,7 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<ScalaCodeStyleSettings>
|
||||
<option name="MULTILINE_STRING_CLOSING_QUOTES_ON_NEW_LINE" value="true" />
|
||||
</ScalaCodeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
Generated
-5
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
Generated
-14
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<annotationProcessing>
|
||||
<profile default="true" name="Default" enabled="true" />
|
||||
<profile name="Maven default annotation processors profile" enabled="true">
|
||||
<sourceOutputDir name="target/generated-sources/annotations" />
|
||||
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
||||
<outputRelativeToContentRoot value="true" />
|
||||
<module name="test.com" />
|
||||
</profile>
|
||||
</annotationProcessing>
|
||||
</component>
|
||||
</project>
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="AmbiguousFieldAccess" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="BooleanMethodNameMustStartWithQuestion" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoreBooleanMethods" value="false" />
|
||||
<option name="ignoreInAnnotationInterface" value="true" />
|
||||
<option name="onlyWarnOnBaseMethods" value="true" />
|
||||
<option name="questionString" value="add,are,can,check,contains,could,endsWith,equals,has,is,matches,must,put,remove,shall,should,startsWith,was,were,will,would" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="BooleanVariableAlwaysNegated" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="BreakStatement" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="BreakStatementWithLabel" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="CastConflictsWithInstanceof" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="CastToIncompatibleInterface" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ComparableImplementedButEqualsNotOverridden" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ConstantOnWrongSideOfComparison" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ConstantValueVariableUse" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ContinueStatement" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ContinueStatementWithLabel" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="CovariantEquals" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="DeclareCollectionAsInterface" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoreLocalVariables" value="false" />
|
||||
<option name="ignorePrivateMethodsAndFields" value="false" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="DefaultNotLastCaseInSwitch" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="EqualsAndHashcode" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="EqualsCalledOnEnumConstant" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="FieldHidesSuperclassField" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="m_ignoreInvisibleFields" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="HardcodedFileSeparators" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="m_recognizeExampleMediaType" value="false" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="HardcodedLineSeparators" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="InnerClassVariableHidesOuterClassVariable" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="m_ignoreInvisibleFields" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="InstanceofIncompatibleInterface" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="IteratorNextDoesNotThrowNoSuchElementException" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="JavaLangImport" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="LabeledStatement" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="LambdaParameterHidingMemberVariable" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="LiteralAsArgToStringEquals" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="LocalVariableHidingMemberVariable" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="m_ignoreInvisibleFields" value="true" />
|
||||
<option name="m_ignoreStaticMethods" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="MagicNumber" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<scope name="Tests" level="WARNING" enabled="false" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="MapReplaceableByEnumMap" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="MarkerInterface" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="MethodOverridesInaccessibleMethodOfSuper" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="MethodOverridesStaticMethod" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="MisspelledEquals" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="NativeMethods" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="NestedSwitchStatement" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="NonFinalFieldInEnum" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ObjectToString" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ObsoleteCollection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoreRequiredObsoleteCollectionTypes" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ParameterHidingMemberVariable" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="m_ignoreInvisibleFields" value="true" />
|
||||
<option name="m_ignoreStaticMethodParametersHidingInstanceFields" value="true" />
|
||||
<option name="m_ignoreForConstructors" value="false" />
|
||||
<option name="m_ignoreForPropertySetters" value="false" />
|
||||
<option name="m_ignoreForAbstractMethods" value="false" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ProtectedMemberInFinalClass" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="RedundantFieldInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="RedundantImplements" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoreSerializable" value="false" />
|
||||
<option name="ignoreCloneable" value="false" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ReuseOfLocalVariable" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="RuntimeExec" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="SamePackageImport" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="SimplifiableEqualsExpression" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="SizeReplaceableByIsEmpty" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="StaticCollection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="m_ignoreWeakCollections" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="StringBufferField" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="SwitchStatementWithConfusingDeclaration" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="SystemExit" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="SystemGC" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="SystemOutErr" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="TestOnlyProblems" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ThreadDumpStack" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ThrowablePrintStackTrace" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="UnnecessaryConstantArrayCreationExpression" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="UnnecessaryConstructor" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="UnnecessaryQualifierForThis" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="UnnecessarySuperConstructor" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="UnnecessarySuperQualifier" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="UnnecessaryThis" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoreAssignments" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="UnnecessaryToStringCall" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="UseOfAWTPeerClass" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="UseOfJDBCDriverClass" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="UseOfObsoleteDateTimeApi" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="UseOfProcessBuilder" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="UseOfSunClasses" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="WeakerAccess" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="SUGGEST_PACKAGE_LOCAL_FOR_MEMBERS" value="true" />
|
||||
<option name="SUGGEST_PACKAGE_LOCAL_FOR_TOP_CLASSES" value="true" />
|
||||
<option name="SUGGEST_PRIVATE_FOR_INNERS" value="false" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<scope name="Tests" level="WARNING" enabled="false" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
Generated
-30
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RemoteRepositoriesConfiguration">
|
||||
<remote-repository>
|
||||
<option name="id" value="central" />
|
||||
<option name="name" value="Central Repository" />
|
||||
<option name="url" value="https://repo.maven.apache.org/maven2" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="ias-snapshots" />
|
||||
<option name="name" value="Infinite Automation Snapshot Repository" />
|
||||
<option name="url" value="https://maven.mangoautomation.net/repository/ias-snapshot/" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="central" />
|
||||
<option name="name" value="Maven Central repository" />
|
||||
<option name="url" value="https://repo1.maven.org/maven2" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="jboss.community" />
|
||||
<option name="name" value="JBoss Community repository" />
|
||||
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="ias-releases" />
|
||||
<option name="name" value="Infinite Automation Release Repository" />
|
||||
<option name="url" value="https://maven.mangoautomation.net/repository/ias-release/" />
|
||||
</remote-repository>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-14
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="MavenProjectsManager">
|
||||
<option name="originalFiles">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/pom.xml" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="openjdk-17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-2
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4" />
|
||||
Generated
-124
@@ -1,124 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Palette2">
|
||||
<group name="Swing">
|
||||
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.png" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
|
||||
</item>
|
||||
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.png" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
|
||||
</item>
|
||||
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.png" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
|
||||
</item>
|
||||
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.png" removable="false" auto-create-binding="false" can-attach-label="true">
|
||||
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
|
||||
</item>
|
||||
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.png" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
|
||||
<initial-values>
|
||||
<property name="text" value="Button" />
|
||||
</initial-values>
|
||||
</item>
|
||||
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.png" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
|
||||
<initial-values>
|
||||
<property name="text" value="RadioButton" />
|
||||
</initial-values>
|
||||
</item>
|
||||
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.png" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
|
||||
<initial-values>
|
||||
<property name="text" value="CheckBox" />
|
||||
</initial-values>
|
||||
</item>
|
||||
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.png" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
|
||||
<initial-values>
|
||||
<property name="text" value="Label" />
|
||||
</initial-values>
|
||||
</item>
|
||||
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.png" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
|
||||
<preferred-size width="150" height="-1" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.png" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
|
||||
<preferred-size width="150" height="-1" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.png" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
|
||||
<preferred-size width="150" height="-1" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.png" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
||||
<preferred-size width="150" height="50" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.png" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
||||
<preferred-size width="150" height="50" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.png" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
||||
<preferred-size width="150" height="50" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.png" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
|
||||
</item>
|
||||
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.png" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
||||
<preferred-size width="150" height="50" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.png" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
|
||||
<preferred-size width="150" height="50" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.png" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
||||
<preferred-size width="150" height="50" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.png" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
|
||||
<preferred-size width="200" height="200" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.png" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
|
||||
<preferred-size width="200" height="200" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.png" removable="false" auto-create-binding="true" can-attach-label="true">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
|
||||
</item>
|
||||
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.png" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
|
||||
</item>
|
||||
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.png" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
|
||||
</item>
|
||||
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.png" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
|
||||
</item>
|
||||
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.png" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
|
||||
<preferred-size width="-1" height="20" />
|
||||
</default-constraints>
|
||||
</item>
|
||||
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.png" removable="false" auto-create-binding="false" can-attach-label="false">
|
||||
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
|
||||
</item>
|
||||
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.png" removable="false" auto-create-binding="true" can-attach-label="false">
|
||||
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
|
||||
</item>
|
||||
</group>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
Backend for controlling the heat pump in Krosno (Frisko controller, Modbus TCP) from a phone.
|
||||
|
||||
@CONTRIBUTING.md
|
||||
|
||||
## For agents
|
||||
|
||||
- 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`, 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.
|
||||
- Never write to the controller `192.168.0.150` and never to the `Dom_Pompa` database.
|
||||
- Ask before force-pushing or rewriting history on Gitea.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Repository rules
|
||||
|
||||
What we build: [docs/spec.md](docs/spec.md). Plan: [docs/roadmap.md](docs/roadmap.md).
|
||||
Reasons: [docs/decisions/](docs/decisions/).
|
||||
|
||||
## Branches and pull requests
|
||||
|
||||
- `master` is always deployable and matches what runs on the Pi.
|
||||
- Every change goes through a pull request on Gitea; no direct commits to `master`.
|
||||
- Branch names: `feat/…`, `fix/…`, `docs/…`, `chore/…`, `refactor/…`.
|
||||
- One topic per PR, small enough to review in one sitting. Merge only with green CI.
|
||||
- `archive/spring-boot-attempt` is reference only. Never merge it.
|
||||
|
||||
## Commits
|
||||
|
||||
- English, Conventional Commits: `fix(health): restart without /health polling`.
|
||||
- Imperative subject ≤ 72 characters; the body says why when it is not obvious.
|
||||
- Each commit builds and passes tests.
|
||||
|
||||
## Code
|
||||
|
||||
- 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.
|
||||
- No dead code, no unused config, no duplicated logic, no speculative abstractions.
|
||||
- Every behaviour change has a test. Modbus is tested against `MockModbusServer` only.
|
||||
|
||||
## Safety
|
||||
|
||||
- Tests and local runs never use the real controller or the `Dom_Pompa` database.
|
||||
- A register is written only if it is on the allowlist (min, max, step) and verified in
|
||||
`docs/frisko/`.
|
||||
- Changes to how or when we write to the controller need a test of the failure case.
|
||||
|
||||
## Knowledge and docs
|
||||
|
||||
Docs are written so an agent needs little context: read only what the task touches.
|
||||
|
||||
| File | Limit | Changes |
|
||||
|---|---|---|
|
||||
| `CLAUDE.md` | 40 lines | rarely |
|
||||
| `docs/spec.md` | 120 lines | when scope or architecture changes |
|
||||
| `docs/roadmap.md` | 80 lines | every milestone |
|
||||
| `docs/decisions/NNN-*.md` | 40 lines | never after acceptance; supersede with a new ADR |
|
||||
| `docs/frisko/<topic>.md` | 120 lines | when knowledge is verified |
|
||||
| `<package>/CLAUDE.md` | 30 lines | rules of that layer |
|
||||
| `research/<source>/` | none | raw vendor material, kept as found; read only on demand |
|
||||
|
||||
- Limits are checked by `scripts/check.sh`.
|
||||
- No status, audit or log documents: the state is git history and the roadmap.
|
||||
- Closing a milestone compresses it to one line in the roadmap and removes its working notes.
|
||||
- English everywhere; the UI is Polish.
|
||||
|
||||
## Configuration, secrets, releases
|
||||
|
||||
- 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.
|
||||
@@ -1,2 +1,25 @@
|
||||
# home-pump
|
||||
|
||||
Heat pump (Frisko RX910-SE21) and home energy for the house in Krosno, from a phone.
|
||||
|
||||
- What we build: [docs/spec.md](docs/spec.md)
|
||||
- Plan and current milestone: [docs/roadmap.md](docs/roadmap.md)
|
||||
- Rules for changes: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- Controller knowledge: [docs/frisko/](docs/frisko/rx910-se21.md)
|
||||
|
||||
| Part | Runs | Readme |
|
||||
|---|---|---|
|
||||
| `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
|
||||
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
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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
|
||||
# The release tag, shown in /api/health; the release workflow passes it.
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=$VERSION
|
||||
USER 10001:10001
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
||||
EXPOSE 8080
|
||||
# /api/live needs no session and does not look at the gateway, so it only tells whether the backend
|
||||
# itself serves requests. busybox wget ships with alpine.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/live" || exit 1
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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` (example `src/test/resources/api/energy.json`) is the house electricity now. `housePowerW` is the whole house by balance, because the Deye `load` registers cover only its backup output (`backupOutputW`): Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, the grid being the house meter's `total_power_active` when live, else the Deye `grid_power_w`; floored at 0, null when any of these readings is missing or stale. `today.houseKwh` is Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged (Deye day counters), null when any is missing.
|
||||
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
|
||||
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
|
||||
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
|
||||
the three values are null (`pi-sensor-stale.json`).
|
||||
|
||||
`GET /api/stats?range=7d|30d` (example `src/test/resources/api/stats.json`; 400 otherwise, 502 when InfluxDB is down) is the heat pump per local
|
||||
day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` with `date`, `compressorHours`, `compressorStarts`, `cwuHours`, `coHours`,
|
||||
`avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
|
||||
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
|
||||
|
||||
`GET /api/program?days=14` (7 to 30, default 14; 400 otherwise, 502 when InfluxDB is down; example `src/test/resources/api/program.json`) is the weekly
|
||||
program as the history shows it, not read from the controller: `weekdays[]` from Monday (`day` 1) to Sunday, each with `cwu` and `room`:
|
||||
`comfort` (intervals `from`/`to`, local "HH:MM", "24:00" at midnight), `observedDays`, `agreeingDays` and `confidence` (`confirmed`: at least
|
||||
two days of that weekday agree within 5 minutes, `seen_once`, `no_data`). A boundary is the middle between the two polls where the period
|
||||
changes (`Periods`), rounded to 5 minutes; an override, ambiguous setpoints (e.g. during our writes) or a gap over 600 s drop the interval.
|
||||
Today is left out. `/api/heat-pump` has `periods` (`cwu`, `room`: `comfort`, `economy`, `unknown`, `override`; `program`, `programText`).
|
||||
|
||||
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
|
||||
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
|
||||
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
|
||||
`mode` (`touEnabled` with a Polish sentence), `schedule` (six slots, each from its start to the next one, the last wraps past midnight),
|
||||
`limits`, and `capacityKwh` = capacity in Ah x a nominal 51.2 V, since the inverter reports Ah only. When stale they are null (`schedule` empty).
|
||||
|
||||
`/api/battery` also has `reserve` (`pct`, `floorPct`: the backup reserve of the app and the lowest the user may set) and `editable`:
|
||||
true when the gateway answers its `/battery/reserve`, i.e. it has battery commands on and `GATEWAY_TOKEN` is right; else `reserve` is null
|
||||
and `editable` false. `POST /api/battery/schedule` (examples `battery-schedule-request.json`, `battery-failed.json`) takes
|
||||
`{"enabled"?, "slots": [{"slot": 1-6, "from"?: "HH:MM", "socPct"?, "gridCharge"?}]}` and `POST /api/battery/reserve` takes `{"pct": 60}`.
|
||||
Same Origin, header and JSON rules as the other writes; the signed-in user goes on as the requester. The gateway's verdict comes back as
|
||||
`{status, message, read}`: 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon, 502 failed (`read` is what the
|
||||
inverter holds), 400 bad body, 503 gateway unreachable or without battery commands (ADR-013).
|
||||
|
||||
`GET /api/audit?range=7d` (`24h`, `7d` default, `30d`; 400 otherwise, 502 when InfluxDB fails) is the change history, example
|
||||
`src/test/resources/api/audit.json`: the gateway's audit rows of the heat pump (`Dom_Pompa.setpoint_audit`) and the battery
|
||||
(`electricity.deye_settings_audit`), read only, as `entries` newest first: `time`, `source` (`heat_pump`, `battery`), `what` (Polish label;
|
||||
the Deye registers are named in `DeyeSettingLabels`), `oldValue`/`newValue` (whole numbers, null when not recorded), `unit` (`°C`, `%`,
|
||||
`HHMM`, `0/1`), `who` (the signed-in user; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
|
||||
|
||||
`GET /api/weather` (example `src/test/resources/api/weather.json`) is the forecast of Open-Meteo for the house and the IMGW warnings that name
|
||||
Krosno (TERYT 1862 or the powiat 1807): `today`, `tomorrow`, `dayAfter` (`date`, `weatherCode` WMO, Polish `description`, `tMax`, `tMin`,
|
||||
`precipitationMm`, `sunshineHours`, `pvForecastKwh`), `now.temperature`, `warnings` (`event`, `level` 1-3, `from`, `to`, `text`; the ones that
|
||||
ended are dropped). `pvFactor` is kWh per MJ/m^2: the Deye plus Fronius energy of the past 14 complete days (from InfluxDB, daily maximum of
|
||||
`pv_today_kwh` and `energy_day_wh`) over the forecast radiation of the same days, so no tilt or azimuth is needed; `pvForecastKwh` is the factor
|
||||
times the day's radiation, and both are null with fewer than 5 days that have both values. The forecast is kept 30 min and the warnings 10 min;
|
||||
when a refresh fails the older answer is served with `stale` true. 503 only when there never was a forecast. The sources are public and need no key.
|
||||
|
||||
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||
without the two URLs.
|
||||
Setpoint changes go only to the gateway; the signed-in user 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 |
|
||||
| `USERS_FILE` | unset | local accounts, see Sign-in; outside the image like `PRESETS_FILE` |
|
||||
| `OIDC_ISSUER` | unset | Keycloak realm URL, e.g. `https://login.mariuszpacek.pl/realms/home`; unset turns OIDC off |
|
||||
| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | required with `OIDC_ISSUER` | the confidential client; redirect `https://pompa.mariuszpacek.pl/api/auth/oidc/callback` |
|
||||
| `OIDC_REQUIRED_ROLE` | unset | a role the user must have: realm role (`realm_access.roles`) or role of this client (`resource_access.<client>.roles`) in the ID token |
|
||||
| `SESSION_SECRET` | required with `USERS_FILE` or `OIDC_ISSUER` | at least 32 bytes signing the cookies, e.g. `openssl rand -base64 48`; changing it signs everyone out |
|
||||
| `SESSION_DAYS` | `30` | how long a session lasts |
|
||||
| `TRUSTED_PROXY_HOPS` | `1` | proxies that append to `X-Forwarded-For` before the backend (Traefik: 1), for the login throttle |
|
||||
| `AUTH_DISABLED` | unset | `true` runs without sign-in (every request is `phone`), for development; refused together with `USERS_FILE` or `OIDC_ISSUER` |
|
||||
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
|
||||
|
||||
```bash
|
||||
mvn -f backend/pom.xml clean package
|
||||
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example AUTH_DISABLED=true \
|
||||
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
|
||||
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
|
||||
```
|
||||
|
||||
## Sign-in (ADR-014)
|
||||
|
||||
Without `USERS_FILE` or `OIDC_ISSUER` the backend refuses to start unless `AUTH_DISABLED=true`. The two ways in are
|
||||
independent: local accounts keep working when Keycloak is down, and the backend never waits for Keycloak at start
|
||||
(discovery and keys are fetched at the first sign-in and kept an hour). Every endpoint but those below answers
|
||||
`401 {"error":"unauthenticated"}` without a valid session; the writes keep their Origin, header and JSON rules.
|
||||
|
||||
| Endpoint | Answer |
|
||||
|---|---|
|
||||
| `GET /api/auth/config` | `{"local", "oidc", "setup"}` booleans (`auth-config.json`); `setup` is true while `USERS_FILE` has no account |
|
||||
| `POST /api/auth/setup` `{"username","password"}` | first account, once: 200 `{"user","method":"local"}` and the cookie; 409 `{"error":"setup_done"}` once any account exists; 400 `weak_password` (under 10 characters) or `invalid_username`; 404 without `USERS_FILE` |
|
||||
| `GET /api/auth/users` | session needed: `[{"username"}]` |
|
||||
| `POST /api/auth/users` `{"username","password"}` | session needed: adds a local account; 200 `{"username"}`, 409 `exists`, 400 as setup |
|
||||
| `POST /api/auth/password` `{"current","new"}` | session needed, local sessions only (403 `local_only` otherwise): 204; 401 `invalid` (counts toward the throttle), 429, 400 `weak_password` |
|
||||
| `POST /api/auth/users/{name}/delete` | session needed: 204 (removing yourself also clears your cookie); 404 `unknown_user`; 409 `last_user` |
|
||||
| `POST /api/auth/login` `{"username","password"}` | 200 `{"user","method":"local"}` and the cookie; 401 `{"error":"invalid"}`; 429 `{"error":"throttled","retryAfterSeconds"}` with `Retry-After`; 404 without `USERS_FILE` |
|
||||
| `POST /api/auth/logout` | 204, cookie cleared |
|
||||
| `GET /api/auth/me` | 200 `{"user","method"}` (`auth-me.json`; `method` is `local`, `oidc`, or `none` with `AUTH_DISABLED`) or 401 |
|
||||
| `GET /api/auth/oidc/start` | 302 to Keycloak; 404 without OIDC |
|
||||
| `GET /api/auth/oidc/callback` | 302 to `/`, or to `/#login?error=oidc_unavailable` (Keycloak down), `oidc_denied` (refused or role missing), `oidc_failed` (bad state, token or code) |
|
||||
| `GET /api/live` | 200 `ok`, for the container healthcheck and external monitors: `/api/health` needs a session now |
|
||||
|
||||
The session is a cookie `hp_session` (HttpOnly, Secure, SameSite=Lax, Path=/) signed with HMAC-SHA256 and holding user, method,
|
||||
issue time and expiry; there is no server state, so a stolen cookie lives until it expires or the secret changes; a `local` session also ends when its account
|
||||
is removed (it is checked on every request).
|
||||
The OIDC flow is authorization code with PKCE S256, state and nonce, in a 10 minute signed cookie `hp_oidc`; the ID token is
|
||||
verified here (RS256 against the JWKS key by `kid`, `iss`, `aud` and `azp`, `exp`/`iat` with 60 s skew, `nonce`).
|
||||
The user name is `preferred_username`, else `email`, else `sub`.
|
||||
|
||||
**Local accounts**: `USERS_FILE` is JSON `[{"username": "tata", "passwordHash": "pbkdf2-sha256$600000$<salt>$<hash>"}]`, a username of
|
||||
1 to 64 of `A-Za-z0-9_.@+-`, a bad file stops the start. The app edits the file: it rewrites it atomically (temp file in the same directory, fsync, rename, mode 600), so mount a **directory** (`./auth:/config/auth`, `USERS_FILE=/config/auth/users.json`) that uid 10001 owns: `chown 10001:10001 auth`. A missing file or one with no accounts is setup mode: the first visitor to the app creates the first account in the app (accepted risk, ADR-014: Traefik admits only the two home networks). Add, remove accounts and change a password in the app; each change is logged (who, what, never a password). A hash can still be made by hand, without echoing the password (asked twice on a terminal;
|
||||
a pipe reads one line):
|
||||
|
||||
```bash
|
||||
docker run --rm -it git.mariuszpacek.pl/mariusz.pacek/home-pump-backend:latest hash-password
|
||||
java -jar backend/target/home-pump-backend-1.0.0.jar hash-password
|
||||
```
|
||||
|
||||
Five login attempts in 15 minutes block that username and, separately, that client address (429, `Retry-After`; an attempt is
|
||||
counted before the password is checked, so parallel guesses cannot outrun it, and a success clears the username); a blocked
|
||||
username is refused even with the right password. At most 4 passwords are hashed at once, more get 429 at once. An unknown username costs one password hash like a wrong password.
|
||||
The address is the entry `TRUSTED_PROXY_HOPS` from the right of `X-Forwarded-For` (Traefik appends the address it saw;
|
||||
what a client put in front is ignored); without it the socket's address.
|
||||
|
||||
**Keycloak client** in the realm of `OIDC_ISSUER`: Client authentication on (confidential), only Standard flow, PKCE method
|
||||
`S256`, valid redirect URI `https://pompa.mariuszpacek.pl/api/auth/oidc/callback`, web origin `https://pompa.mariuszpacek.pl`.
|
||||
Its secret is `OIDC_CLIENT_SECRET`. Without `OIDC_REQUIRED_ROLE`, every user of the realm can sign in; the backend logs a warning at start. For `OIDC_REQUIRED_ROLE`, the client scope `roles` must put the roles into the ID token
|
||||
(its realm and client role mappers: "Add to ID token" on).
|
||||
|
||||
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
|
||||
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
|
||||
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
|
||||
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
|
||||
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
|
||||
(from `/commands`), checked before anything is sent. A timed preset (built-in "Ciepła woda teraz") also takes optional
|
||||
`"hours"` (example `preset-cwu-now-request.json`): a whole number from 1 to 4 replacing its duration; anything else, or
|
||||
hours for a held preset, is 400 `{"error":"invalid_hours"}`. A held value that is not below the current one is 422. The gateway holds those changes until then without
|
||||
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
|
||||
|
||||
`POST /api/refresh` (same Origin, header and JSON rules; send `{}`) is the "Odśwież" button: it asks the gateway for one
|
||||
extra poll of every device and passes its answer through, example `src/test/resources/api/refresh.json`
|
||||
(`requested`, and `skipped` with `retryInSeconds` for devices polled within the last minute). Read-only; 503 when
|
||||
`GATEWAY_TOKEN` is not set or the gateway is unreachable.
|
||||
|
||||
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
|
||||
pending change of that setpoint now and answers with the gateway's verdict, example
|
||||
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
|
||||
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
|
||||
pending), 503 gateway unreachable.
|
||||
|
||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend, so Traefik's
|
||||
access rules (ADR-009) stay in front of the sign-in.
|
||||
|
||||
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
|
||||
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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
|
||||
# Sign-in (README): the accounts directory, writable by uid 10001 (chown 10001:10001 auth); the app rewrites users.json in it. In .env, for example:
|
||||
# USERS_FILE=/config/auth/users.json SESSION_SECRET=<openssl rand -base64 48>
|
||||
# OIDC_ISSUER=https://login.mariuszpacek.pl/realms/home OIDC_CLIENT_ID=home-pump
|
||||
# OIDC_CLIENT_SECRET=<from Keycloak> OIDC_REQUIRED_ROLE=family
|
||||
# OIDC_REDIRECT_URI=https://pompa.mariuszpacek.pl/api/auth/oidc/callback
|
||||
volumes:
|
||||
- ./auth:/config/auth
|
||||
networks: [home-pump]
|
||||
|
||||
networks:
|
||||
home-pump:
|
||||
name: home-pump
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.home</groupId>
|
||||
<artifactId>home-pump-backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<jackson.version>2.20.1</jackson.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<version>1.7.32</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<version>5.8.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<redirectTestOutputToFile>true</redirectTestOutputToFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.4.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>home.backend.Main</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,85 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.adapters.gateway.GatewayHttpClient;
|
||||
import home.backend.adapters.gateway.GatewayHttpCommands;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import home.backend.adapters.influx.InfluxAuditRepository;
|
||||
import home.backend.adapters.influx.InfluxHistoryRepository;
|
||||
import home.backend.adapters.influx.InfluxPvEnergy;
|
||||
import home.backend.adapters.influx.InfluxSettings;
|
||||
import home.backend.adapters.weather.ImgwClient;
|
||||
import home.backend.adapters.weather.OpenMeteoClient;
|
||||
import home.backend.adapters.weather.WeatherSettings;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.ProgramService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.LeaseRenewer;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.application.WeatherService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Wires the adapters and services together; the only place that knows all of them. */
|
||||
final class Backend {
|
||||
|
||||
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
|
||||
/** Warnings are optional, so a slow IMGW must not hold the forecast. */
|
||||
private static final Duration WARNINGS_TIMEOUT = Duration.ofSeconds(3);
|
||||
|
||||
private Backend() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the port but does not start serving.
|
||||
*
|
||||
* @throws IllegalArgumentException when PRESETS_FILE or USERS_FILE is unusable
|
||||
*/
|
||||
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
|
||||
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
|
||||
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 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),
|
||||
new ProgramService(influx, clock),
|
||||
weather(influxSettings, clock), commands,
|
||||
Authentication.create(settings.auth(), clock));
|
||||
}
|
||||
|
||||
private static WeatherService weather(InfluxSettings influx, Clock clock) {
|
||||
var place = WeatherSettings.fromEnvironment(System.getenv());
|
||||
return new WeatherService(
|
||||
new OpenMeteoClient(URI.create("https://api.open-meteo.com"), place, WEATHER_TIMEOUT),
|
||||
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WARNINGS_TIMEOUT),
|
||||
new InfluxPvEnergy(influx), clock);
|
||||
}
|
||||
|
||||
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
|
||||
import home.backend.adapters.auth.PasswordHashCli;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
|
||||
public final class Main {
|
||||
|
||||
private Main() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
if (args.length == 1 && args[0].equals("hash-password")) {
|
||||
System.exit(PasswordHashCli.run(System.console(), System.in, System.out, System.err));
|
||||
}
|
||||
Settings settings;
|
||||
ApiServer server;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
settings.auth().warnings().forEach(w -> System.err.println("home-pump-backend: WARNING: " + w));
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
if (settings.gatewayToken() == null) {
|
||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||
} 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.auth.AuthSettings;
|
||||
import home.backend.adapters.auth.OidcSettings;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Twelve-factor settings: everything comes from environment variables. */
|
||||
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
|
||||
String gatewayToken, Path presetsFile, AuthSettings auth) {
|
||||
|
||||
public static Settings fromEnvironment(Map<String, String> env) {
|
||||
return new Settings(
|
||||
url(env, "GATEWAY_URL"),
|
||||
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)),
|
||||
optional(env, "GATEWAY_TOKEN"),
|
||||
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null),
|
||||
auth(env));
|
||||
}
|
||||
|
||||
private static final int MIN_SECRET_BYTES = 32;
|
||||
|
||||
/** Refuses to start open by accident: sign-in is configured, or AUTH_DISABLED=true says it is meant. */
|
||||
private static AuthSettings auth(Map<String, String> env) {
|
||||
Path usersFile = Optional.ofNullable(optional(env, "USERS_FILE")).map(Path::of).orElse(null);
|
||||
OidcSettings oidc = optional(env, "OIDC_ISSUER") == null ? null : oidc(env);
|
||||
String secret = optional(env, "SESSION_SECRET");
|
||||
boolean disabled = "true".equals(optional(env, "AUTH_DISABLED"));
|
||||
int days = (int) number(env, "SESSION_DAYS", 30, 1, 365);
|
||||
int hops = (int) number(env, "TRUSTED_PROXY_HOPS", 1, 1, 10);
|
||||
boolean configured = usersFile != null || oidc != null;
|
||||
if (disabled && configured) {
|
||||
throw new IllegalArgumentException("AUTH_DISABLED=true contradicts USERS_FILE and OIDC_ISSUER: unset one");
|
||||
}
|
||||
if (!disabled && !configured) {
|
||||
throw new IllegalArgumentException("no sign-in configured: set USERS_FILE and/or OIDC_ISSUER, "
|
||||
+ "or AUTH_DISABLED=true for development only");
|
||||
}
|
||||
if (configured && (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < MIN_SECRET_BYTES)) {
|
||||
throw new IllegalArgumentException("SESSION_SECRET is required and must be at least " + MIN_SECRET_BYTES
|
||||
+ " bytes, e.g. from: openssl rand -base64 48");
|
||||
}
|
||||
return new AuthSettings(usersFile, oidc, secret, days, disabled, hops);
|
||||
}
|
||||
|
||||
private static OidcSettings oidc(Map<String, String> env) {
|
||||
for (String name : new String[] {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI"}) {
|
||||
if (optional(env, name) == null) {
|
||||
throw new IllegalArgumentException(name + " is required when OIDC_ISSUER is set");
|
||||
}
|
||||
}
|
||||
return new OidcSettings(URI.create(optional(env, "OIDC_ISSUER")), optional(env, "OIDC_CLIENT_ID"),
|
||||
optional(env, "OIDC_CLIENT_SECRET"), URI.create(optional(env, "OIDC_REDIRECT_URI")),
|
||||
optional(env, "OIDC_REQUIRED_ROLE"));
|
||||
}
|
||||
|
||||
/** Null when unset or blank. */
|
||||
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) {
|
||||
String value = env.get(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(name + " is required, e.g. " + name + "=http://host:8080");
|
||||
}
|
||||
URI uri = URI.create(value.strip());
|
||||
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;
|
||||
}
|
||||
|
||||
/** Timeouts are whole seconds. */
|
||||
private static long number(Map<String, String> env, String name, long fallback, long min, long max) {
|
||||
String value = env.get(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
long number = Long.parseLong(value.strip());
|
||||
if (number >= min && number <= max) {
|
||||
return number;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// reported below with the same message
|
||||
}
|
||||
throw new IllegalArgumentException(name + " must be a whole number from " + min + " to " + max
|
||||
+ ", got: " + value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* How users sign in. At least one of usersFile and oidc, or disabled; the secret signs the cookies.
|
||||
* proxyHops is how many proxies append to X-Forwarded-For before the backend (Traefik: 1).
|
||||
*/
|
||||
public record AuthSettings(Path usersFile, OidcSettings oidc, String sessionSecret, int sessionDays,
|
||||
boolean disabled, int proxyHops) {
|
||||
|
||||
/** Things worth saying at startup. */
|
||||
public List<String> warnings() {
|
||||
if (oidc != null && oidc.requiredRole() == null) {
|
||||
return List.of("OIDC_REQUIRED_ROLE is not set: every user of the Keycloak realm can sign in");
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
/** Who may use the API: local accounts and OIDC, independent of each other, both ending in a session cookie. */
|
||||
public final class Authentication {
|
||||
|
||||
public static final String LOCAL = "local";
|
||||
|
||||
public enum Status { OK, INVALID, THROTTLED }
|
||||
|
||||
public record Login(Status status, long retryAfterSeconds) {
|
||||
}
|
||||
|
||||
private static final int MAX_FAILURES = 5;
|
||||
private static final Duration THROTTLE_WINDOW = Duration.ofMinutes(15);
|
||||
/** Password hashing is slow on purpose; a flood must not queue up behind it. */
|
||||
private static final int PARALLEL_HASHES = 4;
|
||||
private static final int MIN_PASSWORD = 10;
|
||||
private static final long BUSY_RETRY_SECONDS = 1;
|
||||
|
||||
private final Sessions sessions;
|
||||
private final UserStore users;
|
||||
private final LoginThrottle throttle;
|
||||
private final OidcFlow oidc;
|
||||
private final int proxyHops;
|
||||
private final Semaphore hashing = new Semaphore(PARALLEL_HASHES);
|
||||
|
||||
private Authentication(Sessions sessions, UserStore users, LoginThrottle throttle, OidcFlow oidc, int proxyHops) {
|
||||
this.sessions = sessions;
|
||||
this.users = users;
|
||||
this.throttle = throttle;
|
||||
this.oidc = oidc;
|
||||
this.proxyHops = proxyHops;
|
||||
}
|
||||
|
||||
/** @throws IllegalArgumentException when USERS_FILE is unusable */
|
||||
public static Authentication create(AuthSettings settings, Clock clock) {
|
||||
if (settings.disabled()) {
|
||||
return new Authentication(null, null, null, null, settings.proxyHops());
|
||||
}
|
||||
var tokens = new SignedTokens(settings.sessionSecret().getBytes(StandardCharsets.UTF_8), clock);
|
||||
return new Authentication(new Sessions(tokens, clock, Duration.ofDays(settings.sessionDays())),
|
||||
settings.usersFile() == null ? null : UserStore.load(settings.usersFile()),
|
||||
new LoginThrottle(clock, MAX_FAILURES, THROTTLE_WINDOW),
|
||||
settings.oidc() == null ? null : new OidcFlow(settings.oidc(), tokens, clock),
|
||||
settings.proxyHops());
|
||||
}
|
||||
|
||||
/** For development and tests: every request is the user "phone". */
|
||||
public static Authentication disabled() {
|
||||
return new Authentication(null, null, null, null, 1);
|
||||
}
|
||||
|
||||
public boolean localEnabled() {
|
||||
return users != null;
|
||||
}
|
||||
|
||||
public boolean oidcEnabled() {
|
||||
return oidc != null;
|
||||
}
|
||||
|
||||
public OidcFlow oidc() {
|
||||
return oidc;
|
||||
}
|
||||
|
||||
public int proxyHops() {
|
||||
return proxyHops;
|
||||
}
|
||||
|
||||
public Optional<Session> session(String cookieHeader) {
|
||||
if (sessions == null) {
|
||||
return Optional.of(Session.OPEN);
|
||||
}
|
||||
// A removed account loses its sessions at once; OIDC sessions end with their expiry.
|
||||
return sessions.read(cookieHeader).filter(s -> !LOCAL.equals(s.method()) || users != null && users.exists(s.user()));
|
||||
}
|
||||
|
||||
/** Checks the password unless the user or the address is blocked; a blocked one learns nothing about it. */
|
||||
public Login login(String username, char[] password, String clientIp) {
|
||||
String userKey = "user:" + username.toLowerCase(Locale.ROOT);
|
||||
String ipKey = "ip:" + clientIp;
|
||||
long wait = throttle.tryAcquire(userKey, ipKey);
|
||||
if (wait > 0) {
|
||||
return new Login(Status.THROTTLED, wait);
|
||||
}
|
||||
if (!hashing.tryAcquire()) {
|
||||
throttle.release(userKey, ipKey);
|
||||
return new Login(Status.THROTTLED, BUSY_RETRY_SECONDS);
|
||||
}
|
||||
try {
|
||||
if (users.authenticate(username, password)) {
|
||||
throttle.cleared(userKey);
|
||||
throttle.release(ipKey);
|
||||
return new Login(Status.OK, 0);
|
||||
}
|
||||
return new Login(Status.INVALID, 0);
|
||||
} finally {
|
||||
hashing.release();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean setupNeeded() {
|
||||
return users != null && users.isEmpty();
|
||||
}
|
||||
|
||||
public static boolean strongEnough(String password) {
|
||||
return password.length() >= MIN_PASSWORD;
|
||||
}
|
||||
|
||||
/** Creates the first account, once: OK for the winner, INVALID (setup is done) for everyone after. */
|
||||
public Login setup(String username, char[] password, String clientIp) {
|
||||
String ipKey = "ip:" + clientIp;
|
||||
long wait = throttle.tryAcquire(ipKey);
|
||||
if (wait > 0) {
|
||||
return new Login(Status.THROTTLED, wait);
|
||||
}
|
||||
if (!users.addFirst(username, PasswordHash.create(password))) {
|
||||
return new Login(Status.INVALID, 0);
|
||||
}
|
||||
System.err.println("home-pump-backend: setup created the first account " + username);
|
||||
throttle.release(ipKey);
|
||||
return new Login(Status.OK, 0);
|
||||
}
|
||||
|
||||
public java.util.List<String> usernames() {
|
||||
return users.usernames();
|
||||
}
|
||||
|
||||
/** @return false when the name is taken */
|
||||
public boolean addUser(String by, String username, char[] password) {
|
||||
boolean added = users.add(username, PasswordHash.create(password));
|
||||
System.err.println("home-pump-backend: " + by + " added account " + username + (added ? "" : " (refused, exists)"));
|
||||
return added;
|
||||
}
|
||||
|
||||
/** Checks the current password like a login does (throttled), then sets the new one. */
|
||||
public Login changePassword(String username, char[] current, char[] next, String clientIp) {
|
||||
Login login = login(username, current, clientIp);
|
||||
if (login.status() == Status.OK) {
|
||||
users.changePassword(username, PasswordHash.create(next));
|
||||
System.err.println("home-pump-backend: " + username + " changed their password");
|
||||
}
|
||||
return login;
|
||||
}
|
||||
|
||||
public UserStore.Removal removeUser(String by, String username) {
|
||||
var removal = users.remove(username);
|
||||
System.err.println("home-pump-backend: " + by + " removed account " + username + ": " + removal);
|
||||
return removal;
|
||||
}
|
||||
|
||||
/** The Set-Cookie header value that signs the user in. */
|
||||
public String sessionCookie(String user, String method) {
|
||||
return sessions.setCookie(user, method);
|
||||
}
|
||||
|
||||
public String clearedSessionCookie() {
|
||||
return sessions == null ? "" : sessions.clearCookie();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
/** Unpadded base64url, as tokens and PKCE use it. */
|
||||
final class Base64Url {
|
||||
|
||||
private Base64Url() {
|
||||
}
|
||||
|
||||
static String encode(byte[] bytes) {
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
static byte[] decode(String text) {
|
||||
return Base64.getUrlDecoder().decode(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Reads the Cookie header and builds Set-Cookie headers with the flags every cookie here carries. */
|
||||
final class Cookies {
|
||||
|
||||
private Cookies() {
|
||||
}
|
||||
|
||||
static Optional<String> read(String header, String name) {
|
||||
if (header == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
for (String pair : header.split(";")) {
|
||||
String[] kv = pair.trim().split("=", 2);
|
||||
if (kv.length == 2 && kv[0].equals(name)) {
|
||||
return Optional.of(kv[1]);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
static String set(String name, String value, Duration maxAge, String path) {
|
||||
return name + "=" + value + "; Max-Age=" + maxAge.toSeconds() + attributes(path);
|
||||
}
|
||||
|
||||
static String clear(String name, String path) {
|
||||
return name + "=; Max-Age=0" + attributes(path);
|
||||
}
|
||||
|
||||
private static String attributes(String path) {
|
||||
return "; Path=" + path + "; HttpOnly; Secure; SameSite=Lax";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
|
||||
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||
|
||||
/** Checks an ID token with the JDK alone: RS256 signature, issuer, audience, times, nonce and the required role. */
|
||||
final class IdTokenVerifier {
|
||||
|
||||
/** The signing keys of the identity provider. */
|
||||
interface Keys {
|
||||
PublicKey key(String kid) throws OidcException;
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Duration SKEW = Duration.ofSeconds(60);
|
||||
|
||||
private final OidcSettings settings;
|
||||
private final Clock clock;
|
||||
|
||||
IdTokenVerifier(OidcSettings settings, Clock clock) {
|
||||
this.settings = settings;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** @return the user name of the token */
|
||||
String verify(String idToken, String nonce, Keys keys) throws OidcException {
|
||||
String[] parts = idToken.split("\\.", -1);
|
||||
if (parts.length != 3) {
|
||||
throw new OidcException(FAILED, "the ID token is not a JWT");
|
||||
}
|
||||
JsonNode header = json(parts[0]);
|
||||
if (!"RS256".equals(header.path("alg").asText())) {
|
||||
throw new OidcException(FAILED, "the ID token is not signed with RS256");
|
||||
}
|
||||
verifySignature(parts[0] + "." + parts[1], parts[2], keys.key(header.path("kid").asText()));
|
||||
JsonNode claims = json(parts[1]);
|
||||
checkClaims(claims, nonce);
|
||||
checkRole(claims);
|
||||
return userName(claims);
|
||||
}
|
||||
|
||||
private void checkClaims(JsonNode claims, String nonce) throws OidcException {
|
||||
long now = clock.instant().getEpochSecond();
|
||||
if (!settings.issuerBase().equals(claims.path("iss").asText("").replaceAll("/+$", ""))) {
|
||||
throw new OidcException(FAILED, "wrong issuer");
|
||||
}
|
||||
if (!audiences(claims).contains(settings.clientId())) {
|
||||
throw new OidcException(FAILED, "the token is for another client");
|
||||
}
|
||||
if (audiences(claims).size() > 1 && !settings.clientId().equals(claims.path("azp").asText())) {
|
||||
throw new OidcException(FAILED, "wrong authorized party");
|
||||
}
|
||||
if (!claims.path("exp").canConvertToLong() || claims.get("exp").asLong() + SKEW.toSeconds() <= now) {
|
||||
throw new OidcException(FAILED, "the token has expired");
|
||||
}
|
||||
if (!claims.path("iat").canConvertToLong() || claims.get("iat").asLong() - SKEW.toSeconds() > now) {
|
||||
throw new OidcException(FAILED, "the token is issued in the future");
|
||||
}
|
||||
boolean sameNonce = MessageDigest.isEqual(nonce.getBytes(StandardCharsets.UTF_8),
|
||||
claims.path("nonce").asText("").getBytes(StandardCharsets.UTF_8));
|
||||
if (!sameNonce) {
|
||||
throw new OidcException(FAILED, "wrong nonce");
|
||||
}
|
||||
}
|
||||
|
||||
/** A realm role or a role of this client, as Keycloak puts them in {@code realm_access} and {@code resource_access}. */
|
||||
private void checkRole(JsonNode claims) throws OidcException {
|
||||
String role = settings.requiredRole();
|
||||
if (role == null) {
|
||||
return;
|
||||
}
|
||||
boolean has = contains(claims.path("realm_access").path("roles"), role)
|
||||
|| contains(claims.path("resource_access").path(settings.clientId()).path("roles"), role);
|
||||
if (!has) {
|
||||
throw new OidcException(DENIED, "the user lacks the role " + role);
|
||||
}
|
||||
}
|
||||
|
||||
private String userName(JsonNode claims) throws OidcException {
|
||||
for (String claim : new String[] {"preferred_username", "email", "sub"}) {
|
||||
String name = claims.path(claim).asText("");
|
||||
if (Session.isValidUser(name)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
throw new OidcException(FAILED, "the token has no usable user name");
|
||||
}
|
||||
|
||||
private static List<String> audiences(JsonNode claims) {
|
||||
JsonNode aud = claims.path("aud");
|
||||
var all = new ArrayList<String>();
|
||||
if (aud.isArray()) {
|
||||
aud.forEach(a -> all.add(a.asText()));
|
||||
} else if (aud.isTextual()) {
|
||||
all.add(aud.asText());
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
private static boolean contains(JsonNode array, String value) {
|
||||
for (JsonNode item : array) {
|
||||
if (value.equals(item.asText())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void verifySignature(String signedPart, String signature, PublicKey key) throws OidcException {
|
||||
try {
|
||||
Signature rsa = Signature.getInstance("SHA256withRSA");
|
||||
rsa.initVerify(key);
|
||||
rsa.update(signedPart.getBytes(StandardCharsets.US_ASCII));
|
||||
if (!rsa.verify(Base64Url.decode(signature))) {
|
||||
throw new OidcException(FAILED, "the ID token signature is wrong");
|
||||
}
|
||||
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||
throw new OidcException(FAILED, "the ID token signature cannot be checked", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode json(String part) throws OidcException {
|
||||
try {
|
||||
return JSON.readTree(Base64Url.decode(part));
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
throw new OidcException(FAILED, "the ID token is malformed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Blocks a key (a username or a client address) after too many failures within a window. */
|
||||
public final class LoginThrottle {
|
||||
|
||||
private final Clock clock;
|
||||
private final int maxFailures;
|
||||
private final Duration window;
|
||||
/** The newest failures of each key, at most {@code maxFailures}, oldest first. */
|
||||
private final Map<String, ArrayDeque<Instant>> failures = new HashMap<>();
|
||||
|
||||
public LoginThrottle(Clock clock, int maxFailures, Duration window) {
|
||||
this.clock = clock;
|
||||
this.maxFailures = maxFailures;
|
||||
this.window = window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts an attempt against every key before the password is checked, so parallel guesses cannot
|
||||
* outrun the limit; {@link #release} takes it back.
|
||||
*
|
||||
* @return 0 when the attempt may go on, else the seconds until the most blocked key may try again
|
||||
*/
|
||||
public synchronized long tryAcquire(String... keys) {
|
||||
Instant now = clock.instant();
|
||||
long wait = 0;
|
||||
for (String key : keys) {
|
||||
var times = failures.get(key);
|
||||
if (times != null && times.size() >= maxFailures && times.peekFirst().plus(window).isAfter(now)) {
|
||||
wait = Math.max(wait, Duration.between(now, times.peekFirst().plus(window)).toSeconds() + 1);
|
||||
}
|
||||
}
|
||||
if (wait > 0) {
|
||||
return wait;
|
||||
}
|
||||
failures.values().removeIf(times -> !times.peekLast().plus(window).isAfter(now));
|
||||
for (String key : keys) {
|
||||
var times = failures.computeIfAbsent(key, k -> new ArrayDeque<>());
|
||||
times.addLast(now);
|
||||
if (times.size() > maxFailures) {
|
||||
times.removeFirst();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Takes back the newest attempt of each key. */
|
||||
public synchronized void release(String... keys) {
|
||||
for (String key : keys) {
|
||||
var times = failures.get(key);
|
||||
if (times != null) {
|
||||
times.pollLast();
|
||||
if (times.isEmpty()) {
|
||||
failures.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void cleared(String key) {
|
||||
failures.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
/** Why an OIDC sign-in did not happen; the code is what the login screen is told. */
|
||||
public final class OidcException extends Exception {
|
||||
|
||||
public enum Reason {
|
||||
/** Keycloak did not answer. */
|
||||
UNAVAILABLE("oidc_unavailable"),
|
||||
/** The user said no, or lacks the required role. */
|
||||
DENIED("oidc_denied"),
|
||||
/** Anything else: bad state, a token that does not verify, a refused code. */
|
||||
FAILED("oidc_failed");
|
||||
|
||||
private final String code;
|
||||
|
||||
Reason(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
private final Reason reason;
|
||||
|
||||
public OidcException(Reason reason, String message) {
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public OidcException(Reason reason, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public Reason reason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
|
||||
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||
|
||||
/**
|
||||
* Authorization code flow with PKCE (S256), state and nonce. What must survive the trip to the identity
|
||||
* provider travels in a short-lived signed cookie, so the backend keeps no state.
|
||||
*/
|
||||
public final class OidcFlow {
|
||||
|
||||
/** Where to send the browser, and the cookie to set on that response. */
|
||||
public record Start(URI location, String setCookie) {
|
||||
}
|
||||
|
||||
private static final String COOKIE = "hp_oidc";
|
||||
private static final String PURPOSE = "oidc-login";
|
||||
private static final String PATH = "/api/auth/oidc";
|
||||
private static final Duration LOGIN_TIME = Duration.ofMinutes(10);
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final OidcSettings settings;
|
||||
private final OidcProvider provider;
|
||||
private final IdTokenVerifier verifier;
|
||||
private final SignedTokens tokens;
|
||||
|
||||
OidcFlow(OidcSettings settings, SignedTokens tokens, Clock clock) {
|
||||
this.settings = settings;
|
||||
this.provider = new OidcProvider(settings, TIMEOUT, clock);
|
||||
this.verifier = new IdTokenVerifier(settings, clock);
|
||||
this.tokens = tokens;
|
||||
}
|
||||
|
||||
/** @throws OidcException UNAVAILABLE when the identity provider does not answer */
|
||||
public Start start() throws OidcException {
|
||||
String state = random();
|
||||
String nonce = random();
|
||||
String verifierText = random();
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
query.put("response_type", "code");
|
||||
query.put("client_id", settings.clientId());
|
||||
query.put("redirect_uri", settings.redirectUri().toString());
|
||||
query.put("scope", "openid profile");
|
||||
query.put("state", state);
|
||||
query.put("nonce", nonce);
|
||||
query.put("code_challenge", challenge(verifierText));
|
||||
query.put("code_challenge_method", "S256");
|
||||
URI endpoint = provider.endpoints().authorization();
|
||||
String separator = endpoint.getRawQuery() == null ? "?" : "&";
|
||||
String cookie = Cookies.set(COOKIE, tokens.issue(PURPOSE, LOGIN_TIME,
|
||||
Map.of("state", state, "nonce", nonce, "verifier", verifierText)), LOGIN_TIME, PATH);
|
||||
return new Start(URI.create(endpoint + separator + query.entrySet().stream()
|
||||
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
|
||||
.collect(Collectors.joining("&"))), cookie);
|
||||
}
|
||||
|
||||
/** @return the signed-in user's name */
|
||||
public String callback(Map<String, String> query, String cookieHeader) throws OidcException {
|
||||
Map<String, String> login = Cookies.read(cookieHeader, COOKIE).flatMap(t -> tokens.read(PURPOSE, t))
|
||||
.orElseThrow(() -> new OidcException(FAILED, "no login in progress, or it took too long"));
|
||||
boolean sameState = MessageDigest.isEqual(login.get("state").getBytes(StandardCharsets.UTF_8),
|
||||
query.getOrDefault("state", "").getBytes(StandardCharsets.UTF_8));
|
||||
if (!sameState) {
|
||||
throw new OidcException(FAILED, "wrong state");
|
||||
}
|
||||
if ("access_denied".equals(query.get("error"))) {
|
||||
throw new OidcException(DENIED, "the user denied access");
|
||||
}
|
||||
String code = query.get("code");
|
||||
if (code == null || code.isEmpty()) {
|
||||
throw new OidcException(FAILED, "the identity provider answered with " + printable(query.get("error")));
|
||||
}
|
||||
return verifier.verify(provider.exchange(code, login.get("verifier")), login.get("nonce"), provider);
|
||||
}
|
||||
|
||||
public String clearCookie() {
|
||||
return Cookies.clear(COOKIE, PATH);
|
||||
}
|
||||
|
||||
/** The provider's text goes into the log: letters, digits and a few separators only, and short. */
|
||||
static String printable(String text) {
|
||||
String safe = text == null ? "nothing" : text.replaceAll("[^A-Za-z0-9_. -]", "?");
|
||||
return safe.length() > 40 ? safe.substring(0, 40) + "..." : safe;
|
||||
}
|
||||
|
||||
private static String random() {
|
||||
byte[] bytes = new byte[32];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64Url.encode(bytes);
|
||||
}
|
||||
|
||||
private static String challenge(String verifierText) {
|
||||
try {
|
||||
return Base64Url.encode(MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifierText.getBytes(StandardCharsets.US_ASCII)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 is part of every JDK", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.RSAPublicKeySpec;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
|
||||
import static home.backend.adapters.auth.OidcException.Reason.UNAVAILABLE;
|
||||
|
||||
/**
|
||||
* The identity provider over HTTP: discovery, signing keys and the code exchange. Everything is fetched on first
|
||||
* use and kept, so an identity provider that is down never delays the start and never affects local login.
|
||||
*/
|
||||
final class OidcProvider implements IdTokenVerifier.Keys {
|
||||
|
||||
record Endpoints(URI authorization, URI token, URI jwks) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Duration CACHE = Duration.ofHours(1);
|
||||
/** An unknown key id may mean a rotation; look again, but not on every forged token. */
|
||||
private static final Duration REFETCH_AFTER = Duration.ofMinutes(1);
|
||||
|
||||
private final OidcSettings settings;
|
||||
private final HttpClient http;
|
||||
private final Duration timeout;
|
||||
private final Clock clock;
|
||||
private Endpoints endpoints;
|
||||
private Instant endpointsAt = Instant.MIN;
|
||||
private Map<String, PublicKey> keys = Map.of();
|
||||
private Instant keysAt = Instant.MIN;
|
||||
|
||||
OidcProvider(OidcSettings settings, Duration timeout, Clock clock) {
|
||||
this.settings = settings;
|
||||
this.timeout = timeout;
|
||||
this.clock = clock;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
}
|
||||
|
||||
/** The network calls run outside the lock, so a hanging identity provider holds up only its own callers. */
|
||||
Endpoints endpoints() throws OidcException {
|
||||
synchronized (this) {
|
||||
if (endpoints != null && !endpointsAt.plus(CACHE).isBefore(clock.instant())) {
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
String base = settings.issuerBase();
|
||||
JsonNode doc = getJson(URI.create(base + "/.well-known/openid-configuration"));
|
||||
if (!base.equals(doc.path("issuer").asText("").replaceAll("/+$", ""))) {
|
||||
throw new OidcException(FAILED, "the discovery document names another issuer");
|
||||
}
|
||||
var fresh = new Endpoints(uri(doc, "authorization_endpoint"), uri(doc, "token_endpoint"), uri(doc, "jwks_uri"));
|
||||
synchronized (this) {
|
||||
endpoints = fresh;
|
||||
endpointsAt = clock.instant();
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicKey key(String kid) throws OidcException {
|
||||
synchronized (this) {
|
||||
boolean stale = keysAt.plus(CACHE).isBefore(clock.instant());
|
||||
boolean unknownAndRefetchable = !keys.containsKey(kid)
|
||||
&& keysAt.plus(REFETCH_AFTER).isBefore(clock.instant());
|
||||
if (!stale && !unknownAndRefetchable) {
|
||||
return known(kid);
|
||||
}
|
||||
}
|
||||
Map<String, PublicKey> fresh = parseKeys(getJson(endpoints().jwks()));
|
||||
synchronized (this) {
|
||||
keys = fresh;
|
||||
keysAt = clock.instant();
|
||||
return known(kid);
|
||||
}
|
||||
}
|
||||
|
||||
private PublicKey known(String kid) throws OidcException {
|
||||
PublicKey key = keys.get(kid);
|
||||
if (key == null) {
|
||||
throw new OidcException(FAILED, "no signing key " + kid);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/** @return the ID token the identity provider issued for the code */
|
||||
String exchange(String code, String verifier) throws OidcException {
|
||||
var form = Map.of("grant_type", "authorization_code", "code", code,
|
||||
"redirect_uri", settings.redirectUri().toString(), "code_verifier", verifier);
|
||||
String credentials = encode(settings.clientId()) + ":" + encode(settings.clientSecret());
|
||||
var request = HttpRequest.newBuilder(endpoints().token()).timeout(timeout)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Authorization", "Basic " + Base64.getEncoder()
|
||||
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8)))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(form.entrySet().stream()
|
||||
.map(e -> e.getKey() + "=" + encode(e.getValue())).collect(Collectors.joining("&"))))
|
||||
.build();
|
||||
JsonNode answer = send(request, "the token endpoint");
|
||||
String idToken = answer.path("id_token").asText("");
|
||||
if (idToken.isEmpty()) {
|
||||
throw new OidcException(FAILED, "the token endpoint sent no id_token");
|
||||
}
|
||||
return idToken;
|
||||
}
|
||||
|
||||
private JsonNode getJson(URI uri) throws OidcException {
|
||||
return send(HttpRequest.newBuilder(uri).timeout(timeout).GET().build(), uri.getPath());
|
||||
}
|
||||
|
||||
private JsonNode send(HttpRequest request, String what) throws OidcException {
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() >= 500) {
|
||||
throw new OidcException(UNAVAILABLE, what + " answered " + response.statusCode());
|
||||
}
|
||||
if (response.statusCode() != 200) {
|
||||
throw new OidcException(FAILED, what + " answered " + response.statusCode());
|
||||
}
|
||||
return JSON.readTree(response.body());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new OidcException(UNAVAILABLE, what + " was interrupted", e);
|
||||
} catch (IOException e) {
|
||||
throw new OidcException(UNAVAILABLE, what + " is unreachable: " + e, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** A key that cannot be read is skipped, so one odd entry does not lock everyone out. */
|
||||
private static Map<String, PublicKey> parseKeys(JsonNode jwks) {
|
||||
Map<String, PublicKey> keys = new HashMap<>();
|
||||
for (JsonNode jwk : jwks.path("keys")) {
|
||||
boolean signingRsa = "RSA".equals(jwk.path("kty").asText()) && !"enc".equals(jwk.path("use").asText());
|
||||
if (signingRsa && jwk.hasNonNull("kid")) {
|
||||
try {
|
||||
keys.put(jwk.get("kid").asText(), KeyFactory.getInstance("RSA").generatePublic(
|
||||
new RSAPublicKeySpec(integer(jwk, "n"), integer(jwk, "e"))));
|
||||
} catch (GeneralSecurityException | IllegalArgumentException e) {
|
||||
System.err.println("home-pump-backend: skipping unusable signing key " + jwk.get("kid").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static BigInteger integer(JsonNode jwk, String field) {
|
||||
return new BigInteger(1, Base64Url.decode(jwk.path(field).asText()));
|
||||
}
|
||||
|
||||
private static URI uri(JsonNode doc, String field) throws OidcException {
|
||||
String value = doc.path(field).asText("");
|
||||
if (value.isEmpty()) {
|
||||
throw new OidcException(FAILED, "the discovery document has no " + field);
|
||||
}
|
||||
return URI.create(value);
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
/** requiredRole is null when any user of the realm may sign in. */
|
||||
public record OidcSettings(URI issuer, String clientId, String clientSecret, URI redirectUri, String requiredRole) {
|
||||
|
||||
/** The issuer without a trailing slash, the form tokens and discovery are compared in. */
|
||||
String issuerBase() {
|
||||
return issuer.toString().replaceAll("/+$", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/** Password hashes as {@code pbkdf2-sha256$<iterations>$<salt b64>$<hash b64>}: the cost travels with the hash. */
|
||||
public final class PasswordHash {
|
||||
|
||||
private static final String SCHEME = "pbkdf2-sha256";
|
||||
private static final int ITERATIONS = 600_000;
|
||||
/** A file with a cheaper hash is refused, so a weak one cannot slip in. */
|
||||
private static final int MIN_ITERATIONS = 310_000;
|
||||
private static final int SALT_BYTES = 16;
|
||||
private static final int HASH_BITS = 256;
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private record Parsed(int iterations, byte[] salt, byte[] hash) {
|
||||
}
|
||||
|
||||
private PasswordHash() {
|
||||
}
|
||||
|
||||
public static String create(char[] password) {
|
||||
byte[] salt = new byte[SALT_BYTES];
|
||||
RANDOM.nextBytes(salt);
|
||||
return String.join("$", SCHEME, String.valueOf(ITERATIONS), Base64.getEncoder().encodeToString(salt),
|
||||
Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS, HASH_BITS)));
|
||||
}
|
||||
|
||||
/** Compares in constant time. */
|
||||
public static boolean matches(String stored, char[] password) {
|
||||
Parsed parsed = parse(stored);
|
||||
byte[] actual = derive(password, parsed.salt(), parsed.iterations(), parsed.hash().length * 8);
|
||||
return MessageDigest.isEqual(parsed.hash(), actual);
|
||||
}
|
||||
|
||||
/** @throws IllegalArgumentException when the text is not a hash this class made or accepts */
|
||||
public static void requireValid(String stored) {
|
||||
parse(stored);
|
||||
}
|
||||
|
||||
private static Parsed parse(String stored) {
|
||||
String[] parts = stored == null ? new String[0] : stored.split("\\$");
|
||||
if (parts.length != 4 || !SCHEME.equals(parts[0])) {
|
||||
throw new IllegalArgumentException("a password hash must look like " + SCHEME
|
||||
+ "$<iterations>$<salt>$<hash>; create one with: hash-password");
|
||||
}
|
||||
try {
|
||||
int iterations = Integer.parseInt(parts[1]);
|
||||
byte[] salt = Base64.getDecoder().decode(parts[2]);
|
||||
byte[] hash = Base64.getDecoder().decode(parts[3]);
|
||||
if (iterations < MIN_ITERATIONS || salt.length < SALT_BYTES || hash.length < HASH_BITS / 8) {
|
||||
throw new IllegalArgumentException("password hash is too weak: at least " + MIN_ITERATIONS
|
||||
+ " iterations, 16 bytes of salt, a 32 byte hash");
|
||||
}
|
||||
return new Parsed(iterations, salt, hash);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// NumberFormatException and a bad base64 are both IllegalArgumentException
|
||||
throw new IllegalArgumentException("password hash is malformed or too weak (at least " + MIN_ITERATIONS
|
||||
+ " iterations, 16 bytes of salt, a 32 byte hash)", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] derive(char[] password, byte[] salt, int iterations, int bits) {
|
||||
var spec = new PBEKeySpec(password, salt, iterations, bits);
|
||||
try {
|
||||
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("PBKDF2WithHmacSHA256 is part of every JDK", e);
|
||||
} finally {
|
||||
spec.clearPassword();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.Console;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
/** {@code hash-password}: prints the hash for USERS_FILE; on a terminal the password is asked twice, unechoed. */
|
||||
public final class PasswordHashCli {
|
||||
|
||||
private PasswordHashCli() {
|
||||
}
|
||||
|
||||
/** @return the exit code */
|
||||
public static int run(Console console, InputStream stdin, PrintStream out, PrintStream err) throws IOException {
|
||||
char[] password;
|
||||
if (console != null) {
|
||||
password = console.readPassword("Password: ");
|
||||
char[] again = console.readPassword("Again: ");
|
||||
if (password == null || !Arrays.equals(password, again)) {
|
||||
err.println("The passwords differ.");
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
String line = new BufferedReader(new InputStreamReader(stdin, StandardCharsets.UTF_8)).readLine();
|
||||
password = line == null ? null : line.toCharArray();
|
||||
}
|
||||
if (password == null || password.length == 0) {
|
||||
err.println("No password given.");
|
||||
return 1;
|
||||
}
|
||||
out.println(PasswordHash.create(password));
|
||||
Arrays.fill(password, '\0');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Who is signed in and how. {@code method} is {@code local}, {@code oidc} or {@code none} (AUTH_DISABLED). */
|
||||
public record Session(String user, String method, Instant issuedAt) {
|
||||
|
||||
/** The name goes to the gateway's audit log as a header, so only plain characters are allowed. */
|
||||
public static final String USER_PATTERN = "[\\w.@+-]{1,64}";
|
||||
private static final Pattern USER = Pattern.compile(USER_PATTERN);
|
||||
|
||||
/** Every request is this one when authentication is switched off for development. */
|
||||
static final Session OPEN = new Session("phone", "none", Instant.EPOCH);
|
||||
|
||||
public static boolean isValidUser(String name) {
|
||||
return USER.matcher(name).matches();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** The session cookie: a signed token that holds the user, the method and when it was issued. */
|
||||
final class Sessions {
|
||||
|
||||
static final String COOKIE = "hp_session";
|
||||
private static final String PURPOSE = "session";
|
||||
|
||||
private final SignedTokens tokens;
|
||||
private final Clock clock;
|
||||
private final Duration lifetime;
|
||||
|
||||
Sessions(SignedTokens tokens, Clock clock, Duration lifetime) {
|
||||
this.tokens = tokens;
|
||||
this.clock = clock;
|
||||
this.lifetime = lifetime;
|
||||
}
|
||||
|
||||
String setCookie(String user, String method) {
|
||||
String token = tokens.issue(PURPOSE, lifetime, Map.of("user", user, "method", method,
|
||||
"iat", String.valueOf(clock.instant().getEpochSecond())));
|
||||
return Cookies.set(COOKIE, token, lifetime, "/");
|
||||
}
|
||||
|
||||
String clearCookie() {
|
||||
return Cookies.clear(COOKIE, "/");
|
||||
}
|
||||
|
||||
Optional<Session> read(String cookieHeader) {
|
||||
return Cookies.read(cookieHeader, COOKIE).flatMap(token -> tokens.read(PURPOSE, token)).map(claims ->
|
||||
new Session(claims.get("user"), claims.get("method"),
|
||||
Instant.ofEpochSecond(Long.parseLong(claims.get("iat")))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Tamper-proof, expiring text for cookies: {@code base64url(json).base64url(HMAC-SHA256)}. */
|
||||
final class SignedTokens {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final TypeReference<Map<String, String>> CLAIMS = new TypeReference<>() {
|
||||
};
|
||||
private static final String HMAC = "HmacSHA256";
|
||||
|
||||
private final SecretKeySpec key;
|
||||
private final Clock clock;
|
||||
|
||||
SignedTokens(byte[] secret, Clock clock) {
|
||||
this.key = new SecretKeySpec(secret, HMAC);
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** The purpose keeps a token made for one use (a session) from passing as another (a login in progress). */
|
||||
String issue(String purpose, Duration lifetime, Map<String, String> claims) {
|
||||
var all = new HashMap<>(claims);
|
||||
all.put("purpose", purpose);
|
||||
all.put("exp", String.valueOf(clock.instant().plus(lifetime).getEpochSecond()));
|
||||
try {
|
||||
String payload = Base64Url.encode(JSON.writeValueAsBytes(all));
|
||||
return payload + "." + Base64Url.encode(sign(payload));
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
Optional<Map<String, String>> read(String purpose, String token) {
|
||||
try {
|
||||
String[] parts = token.split("\\.", -1);
|
||||
if (parts.length != 2 || !MessageDigest.isEqual(sign(parts[0]), Base64Url.decode(parts[1]))) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Map<String, String> claims = JSON.readValue(Base64Url.decode(parts[0]), CLAIMS);
|
||||
boolean valid = purpose.equals(claims.get("purpose"))
|
||||
&& Long.parseLong(claims.get("exp")) > clock.instant().getEpochSecond();
|
||||
return valid ? Optional.of(claims) : Optional.empty();
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] sign(String payload) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC);
|
||||
mac.init(key);
|
||||
return mac.doFinal(payload.getBytes(StandardCharsets.US_ASCII));
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException(HMAC + " is part of every JDK", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package home.backend.adapters.auth;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The local accounts of USERS_FILE: {@code [{"username", "passwordHash"}]}. A missing file is an empty store
|
||||
* (setup mode). Every change is written back atomically before it counts, under one lock.
|
||||
*/
|
||||
public final class UserStore {
|
||||
|
||||
public enum Removal { REMOVED, UNKNOWN, LAST }
|
||||
|
||||
private record Entry(String username, String passwordHash) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
/** What an unknown user is checked against, so that it costs as much as a wrong password. */
|
||||
private static final String DECOY = PasswordHash.create(randomPassword());
|
||||
|
||||
private final Path file;
|
||||
private Map<String, String> hashes;
|
||||
|
||||
UserStore(Path file, Map<String, String> hashes) {
|
||||
this.file = file;
|
||||
this.hashes = new LinkedHashMap<>(hashes);
|
||||
}
|
||||
|
||||
/** @throws IllegalArgumentException when the file exists but is unreadable or holds a bad account */
|
||||
public static UserStore load(Path file) {
|
||||
Map<String, String> hashes = new LinkedHashMap<>();
|
||||
if (Files.exists(file)) {
|
||||
List<Entry> entries;
|
||||
try {
|
||||
entries = JSON.readValue(file.toFile(), new TypeReference<>() {
|
||||
});
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("USERS_FILE " + file + " is not usable: " + e.getMessage(), e);
|
||||
}
|
||||
for (Entry entry : entries) {
|
||||
if (entry.username() == null || !Session.isValidUser(entry.username())) {
|
||||
throw new IllegalArgumentException("USERS_FILE: a username must match " + Session.USER_PATTERN);
|
||||
}
|
||||
PasswordHash.requireValid(entry.passwordHash());
|
||||
if (hashes.put(entry.username(), entry.passwordHash()) != null) {
|
||||
throw new IllegalArgumentException("USERS_FILE: duplicate username " + entry.username());
|
||||
}
|
||||
}
|
||||
}
|
||||
return new UserStore(file, hashes);
|
||||
}
|
||||
|
||||
public synchronized boolean isEmpty() {
|
||||
return hashes.isEmpty();
|
||||
}
|
||||
|
||||
public synchronized boolean exists(String username) {
|
||||
return hashes.containsKey(username);
|
||||
}
|
||||
|
||||
public synchronized List<String> usernames() {
|
||||
return List.copyOf(hashes.keySet());
|
||||
}
|
||||
|
||||
public boolean authenticate(String username, char[] password) {
|
||||
String stored;
|
||||
synchronized (this) {
|
||||
stored = hashes.get(username);
|
||||
}
|
||||
boolean matches = PasswordHash.matches(stored == null ? DECOY : stored, password);
|
||||
return stored != null && matches;
|
||||
}
|
||||
|
||||
/** The first account; false when there already is one, so only one setup can win. */
|
||||
public synchronized boolean addFirst(String username, String hash) {
|
||||
return hashes.isEmpty() && put(username, hash);
|
||||
}
|
||||
|
||||
/** @return false when the name is taken */
|
||||
public synchronized boolean add(String username, String hash) {
|
||||
return !hashes.containsKey(username) && put(username, hash);
|
||||
}
|
||||
|
||||
/** @return false when there is no such account */
|
||||
public synchronized boolean changePassword(String username, String hash) {
|
||||
return hashes.containsKey(username) && put(username, hash);
|
||||
}
|
||||
|
||||
public synchronized Removal remove(String username) {
|
||||
if (!hashes.containsKey(username)) {
|
||||
return Removal.UNKNOWN;
|
||||
}
|
||||
if (hashes.size() == 1) {
|
||||
return Removal.LAST;
|
||||
}
|
||||
var next = new LinkedHashMap<>(hashes);
|
||||
next.remove(username);
|
||||
write(next);
|
||||
hashes = next;
|
||||
return Removal.REMOVED;
|
||||
}
|
||||
|
||||
private boolean put(String username, String hash) {
|
||||
var next = new LinkedHashMap<>(hashes);
|
||||
next.put(username, hash);
|
||||
write(next);
|
||||
hashes = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Temp file next to the target, synced, then renamed over it; the old file stays if anything fails. */
|
||||
private void write(Map<String, String> accounts) {
|
||||
Path directory = file.toAbsolutePath().getParent();
|
||||
Path temp = null;
|
||||
try {
|
||||
temp = Files.createTempFile(directory, "users", ".tmp",
|
||||
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
||||
Files.write(temp, JSON.writerWithDefaultPrettyPrinter().writeValueAsBytes(accounts.entrySet().stream()
|
||||
.map(e -> new Entry(e.getKey(), e.getValue())).toList()));
|
||||
try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException e) {
|
||||
if (temp != null) {
|
||||
temp.toFile().delete();
|
||||
}
|
||||
throw new UncheckedIOException("cannot write USERS_FILE " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static char[] randomPassword() {
|
||||
byte[] bytes = new byte[24];
|
||||
new SecureRandom().nextBytes(bytes);
|
||||
return Base64.getEncoder().encodeToString(bytes).toCharArray();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayClient;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
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.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
|
||||
public class GatewayHttpClient implements GatewayClient {
|
||||
|
||||
private record Health(String version, List<DeviceHealth> devices) {
|
||||
}
|
||||
|
||||
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
|
||||
private record DeviceReadings(String name, List<Reading> readings) {
|
||||
}
|
||||
|
||||
private record Reading(String database, String measurement, Map<String, Double> fields, Instant time) {
|
||||
}
|
||||
|
||||
// Unknown properties are ignored so the gateway can add fields without breaking us.
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private final URI baseUri;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
|
||||
public GatewayHttpClient(URI baseUri, Duration timeout) {
|
||||
this.baseUri = baseUri;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GatewaySnapshot snapshot() throws IOException {
|
||||
var health = JSON.readValue(get("/health", true), Health.class);
|
||||
var readings = JSON.readValue(get("/snapshot", false), DeviceReadings[].class);
|
||||
Map<String, List<Measurement>> measurements = Arrays.stream(readings)
|
||||
.collect(Collectors.toMap(DeviceReadings::name, GatewayHttpClient::measurements));
|
||||
var devices = health.devices().stream()
|
||||
.map(device -> new DeviceSnapshot(device.name(), device.status().equals("UP"),
|
||||
device.lastSuccess(), device.lastError(),
|
||||
measurements.getOrDefault(device.name(), List.of())))
|
||||
.toList();
|
||||
return new GatewaySnapshot(health.version(), devices);
|
||||
}
|
||||
|
||||
private static List<Measurement> measurements(DeviceReadings device) {
|
||||
return device.readings().stream()
|
||||
.map(r -> new Measurement(r.database(), r.measurement(), r.fields(), r.time()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** A gateway with a device down answers /health with 503 and a valid body. */
|
||||
private byte[] get(String path, boolean downIsValid) throws IOException {
|
||||
var request = HttpRequest.newBuilder(baseUri.resolve(path)).timeout(timeout).GET().build();
|
||||
try {
|
||||
var response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
int status = response.statusCode();
|
||||
if (status != 200 && !(downIsValid && status == 503)) {
|
||||
throw new IOException("gateway " + path + " answered " + status);
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted while calling gateway " + path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
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.adapters.auth.Authentication;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.GatewayRefresh;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.ProgramService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.application.WeatherService;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
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. */
|
||||
public class ApiServer {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.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 hours}: how long a timed preset lasts; {@code values}: the user's own value per setpoint of the
|
||||
* preset, by setpoint name.
|
||||
*/
|
||||
private record PresetRequest(String until, Integer hours, Map<String, Integer> values) {
|
||||
|
||||
PresetRequest {
|
||||
if (values == null) {
|
||||
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");
|
||||
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 SessionFilter sessionFilter;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/** Binds the port now; 0 picks a free one. */
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
|
||||
BatteryService battery, AuditService audit, StatsService stats, ProgramService program, WeatherService weather,
|
||||
GatewayRefresh refresh, Authentication auth) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(executor);
|
||||
sessionFilter = new SessionFilter(auth);
|
||||
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
|
||||
open("/", exchange -> {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
});
|
||||
var signIn = new AuthEndpoints(auth);
|
||||
openRoute("/api/auth/config", signIn::config);
|
||||
openRoute("/api/auth/login", signIn::login);
|
||||
openRoute("/api/auth/logout", signIn::logout);
|
||||
openRoute("/api/auth/me", signIn::me);
|
||||
openRoute("/api/auth/setup", signIn::setup);
|
||||
openRoute("/api/auth/users", signIn::users);
|
||||
openRoute("/api/auth/password", signIn::password);
|
||||
open("/api/auth/users/", signIn::deleteUser);
|
||||
openRoute("/api/auth/oidc/start", signIn::oidcStart);
|
||||
openRoute("/api/auth/oidc/callback", signIn::oidcCallback);
|
||||
// The container healthcheck: no session, nothing in the answer.
|
||||
openRoute("/api/live", exchange -> handle(exchange, Map.of("GET", () -> {
|
||||
exchange.sendResponseHeaders(200, 2);
|
||||
exchange.getResponseBody().write("ok".getBytes(StandardCharsets.US_ASCII));
|
||||
})));
|
||||
api("/api/heat-pump", query -> heatPump.current());
|
||||
api("/api/energy", query -> energy.current());
|
||||
api("/api/energy/details", query -> energyDetails.current());
|
||||
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());
|
||||
route("/api/history", exchange -> handleHistory(exchange, history));
|
||||
route("/api/audit", exchange -> handleAudit(exchange, audit));
|
||||
route("/api/stats", exchange -> handleStats(exchange, stats));
|
||||
route("/api/program", exchange -> handleProgram(exchange, program));
|
||||
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
||||
"GET", () -> getSetpoints(exchange, setpoints),
|
||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||
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)))));
|
||||
}
|
||||
|
||||
private void route(String path, HttpHandler handler) {
|
||||
context(path, exactly(path, handler));
|
||||
}
|
||||
|
||||
private void openRoute(String path, HttpHandler handler) {
|
||||
open(path, exactly(path, handler));
|
||||
}
|
||||
|
||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||
private static HttpHandler exactly(String path, HttpHandler handler) {
|
||||
return exchange -> {
|
||||
if (path.equals(exchange.getRequestURI().getPath())) {
|
||||
handler.handle(exchange);
|
||||
} else {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Needs a session. */
|
||||
private void context(String path, HttpHandler handler) {
|
||||
var context = server.createContext(path, handler);
|
||||
context.getFilters().add(SECURITY_HEADERS);
|
||||
context.getFilters().add(sessionFilter);
|
||||
}
|
||||
|
||||
/** Answers without a session. */
|
||||
private void open(String path, HttpHandler handler) {
|
||||
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
server.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
server.stop(STOP_GRACE_SECONDS);
|
||||
executor.shutdown();
|
||||
}
|
||||
|
||||
public int port() {
|
||||
return server.getAddress().getPort();
|
||||
}
|
||||
|
||||
private interface Gateway {
|
||||
Object get(Map<String, String> query) throws IOException;
|
||||
}
|
||||
|
||||
/** Gateway trouble on a live-state endpoint is the gateway being unreachable: 503. */
|
||||
private void api(String path, Gateway gateway) {
|
||||
route(path, exchange -> handle(exchange, Map.of("GET", () -> {
|
||||
try {
|
||||
send(exchange, 200, gateway.get(Map.of()));
|
||||
} catch (IOException e) {
|
||||
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.
|
||||
*/
|
||||
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, null) : JSON.readValue(body, PresetRequest.class);
|
||||
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
|
||||
var result = setpoints.applyPreset(id, until, request.hours(), request.values(),
|
||||
user(exchange));
|
||||
if (result.isPresent()) {
|
||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||
} else {
|
||||
send(exchange, 404, error("no such preset"));
|
||||
}
|
||||
} catch (SetpointService.InvalidHoursException e) {
|
||||
send(exchange, 400, error("invalid_hours"));
|
||||
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
|
||||
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
|
||||
+ "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;
|
||||
};
|
||||
}
|
||||
|
||||
/** The signed-in user, set by the session filter. */
|
||||
private static String user(HttpExchange exchange) {
|
||||
return (String) exchange.getAttribute(SessionFilter.USER_ATTRIBUTE);
|
||||
}
|
||||
|
||||
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
||||
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")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} 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 void handleProgram(HttpExchange exchange, ProgramService program) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
try {
|
||||
send(exchange, 200, program.program(parseQuery(exchange.getRequestURI().getRawQuery()).get("days")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("program unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
interface Action {
|
||||
void run() throws IOException;
|
||||
}
|
||||
|
||||
interface Write {
|
||||
void run(byte[] body) throws IOException;
|
||||
}
|
||||
|
||||
static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
|
||||
try (exchange) {
|
||||
Action action = byMethod.get(exchange.getRequestMethod());
|
||||
if (action == null) {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void send(HttpExchange exchange, int status, Object body) throws IOException {
|
||||
byte[] bytes = JSON.writeValueAsBytes(body);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
}
|
||||
|
||||
private static Map<String, String> error(String message) {
|
||||
return Map.of("error", message);
|
||||
}
|
||||
|
||||
static Map<String, String> parseQuery(String raw) {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
if (raw == null) {
|
||||
return query;
|
||||
}
|
||||
for (String pair : raw.split("&")) {
|
||||
String[] kv = pair.split("=", 2);
|
||||
query.put(decode(kv[0]), kv.length == 2 ? decode(kv[1]) : "");
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
private static String decode(String s) {
|
||||
return URLDecoder.decode(s, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.adapters.auth.OidcException;
|
||||
import home.backend.adapters.auth.Session;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** The endpoints under {@code /api/auth/}: sign-in needs no session, managing accounts does. */
|
||||
final class AuthEndpoints {
|
||||
|
||||
private record LoginRequest(String username, String password) {
|
||||
}
|
||||
|
||||
private record PasswordRequest(String current, @JsonProperty("new") String next) {
|
||||
}
|
||||
|
||||
private static final Pattern DELETE_PATH = Pattern.compile("/api/auth/users/([^/]{1,64})/delete");
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final int MAX_CREDENTIAL_LENGTH = 256;
|
||||
|
||||
private final Authentication auth;
|
||||
|
||||
AuthEndpoints(Authentication auth) {
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
void config(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("GET", () -> ApiServer.send(exchange, 200,
|
||||
Map.of("local", auth.localEnabled(), "oidc", auth.oidcEnabled(),
|
||||
"setup", auth.setupNeeded()))));
|
||||
}
|
||||
|
||||
void login(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> postLogin(exchange, body))));
|
||||
}
|
||||
|
||||
void logout(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
|
||||
String cookie = auth.clearedSessionCookie();
|
||||
if (!cookie.isEmpty()) {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", cookie);
|
||||
}
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
})));
|
||||
}
|
||||
|
||||
void me(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("GET", () -> {
|
||||
var session = auth.session(SessionFilter.cookies(exchange));
|
||||
if (session.isPresent()) {
|
||||
ApiServer.send(exchange, 200, user(session.get()));
|
||||
} else {
|
||||
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void oidcStart(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("GET", () -> {
|
||||
if (!auth.oidcEnabled()) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var start = auth.oidc().start();
|
||||
exchange.getResponseHeaders().add("Set-Cookie", start.setCookie());
|
||||
redirect(exchange, start.location().toString());
|
||||
} catch (OidcException e) {
|
||||
redirectWithError(exchange, e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void oidcCallback(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("GET", () -> {
|
||||
if (!auth.oidcEnabled()) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.oidc().clearCookie());
|
||||
try {
|
||||
String name = auth.oidc().callback(ApiServer.parseQuery(exchange.getRequestURI().getRawQuery()),
|
||||
SessionFilter.cookies(exchange));
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(name, "oidc"));
|
||||
redirect(exchange, "/");
|
||||
} catch (OidcException e) {
|
||||
redirectWithError(exchange, e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void postLogin(HttpExchange exchange, byte[] body) throws IOException {
|
||||
if (!auth.localEnabled()) {
|
||||
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||
return;
|
||||
}
|
||||
LoginRequest request = credentials(exchange, body);
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
String ip = ClientIp.of(exchange, auth.proxyHops());
|
||||
var login = auth.login(request.username(), request.password().toCharArray(), ip);
|
||||
switch (login.status()) {
|
||||
case OK -> {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
|
||||
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
|
||||
}
|
||||
case INVALID -> {
|
||||
System.err.println("home-pump-backend: failed login from " + ip);
|
||||
ApiServer.send(exchange, 401, Map.of("error", "invalid"));
|
||||
}
|
||||
case THROTTLED -> throttled(exchange, login);
|
||||
}
|
||||
}
|
||||
|
||||
/** The username and password of the body, or null after answering 400. */
|
||||
private static LoginRequest credentials(HttpExchange exchange, byte[] body) throws IOException {
|
||||
LoginRequest request;
|
||||
try {
|
||||
request = JSON.readValue(body, LoginRequest.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
request = null;
|
||||
}
|
||||
if (request == null || request.username() == null || request.password() == null
|
||||
|| request.username().length() > MAX_CREDENTIAL_LENGTH
|
||||
|| request.password().length() > MAX_CREDENTIAL_LENGTH) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "expected {\"username\", \"password\"}"));
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private static void throttled(HttpExchange exchange, Authentication.Login login) throws IOException {
|
||||
exchange.getResponseHeaders().set("Retry-After", String.valueOf(login.retryAfterSeconds()));
|
||||
ApiServer.send(exchange, 429, Map.of("error", "throttled", "retryAfterSeconds", login.retryAfterSeconds()));
|
||||
}
|
||||
|
||||
/** True when the name and password may become an account; else answers 400. */
|
||||
private static boolean acceptable(HttpExchange exchange, String username, String password) throws IOException {
|
||||
if (!Session.isValidUser(username)) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "invalid_username"));
|
||||
return false;
|
||||
}
|
||||
if (!Authentication.strongEnough(password)) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void setup(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
|
||||
if (!auth.localEnabled()) {
|
||||
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||
return;
|
||||
}
|
||||
LoginRequest request = credentials(exchange, body);
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
if (!auth.setupNeeded()) {
|
||||
ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
|
||||
return;
|
||||
}
|
||||
if (!acceptable(exchange, request.username(), request.password())) {
|
||||
return;
|
||||
}
|
||||
var result = auth.setup(request.username(), request.password().toCharArray(),
|
||||
ClientIp.of(exchange, auth.proxyHops()));
|
||||
switch (result.status()) {
|
||||
case OK -> {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
|
||||
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
|
||||
}
|
||||
case INVALID -> ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
|
||||
case THROTTLED -> throttled(exchange, result);
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
/** Runs the action for a signed-in user of a configured local store; else answers 401 or 404. */
|
||||
private void asUser(HttpExchange exchange, SignedIn action) throws IOException {
|
||||
var session = auth.session(SessionFilter.cookies(exchange));
|
||||
if (session.isEmpty()) {
|
||||
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||
} else if (!auth.localEnabled()) {
|
||||
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
|
||||
} else {
|
||||
action.run(session.get());
|
||||
}
|
||||
}
|
||||
|
||||
private interface SignedIn {
|
||||
void run(Session session) throws IOException;
|
||||
}
|
||||
|
||||
void users(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of(
|
||||
"GET", () -> asUser(exchange, session -> ApiServer.send(exchange, 200,
|
||||
auth.usernames().stream().map(name -> Map.of("username", name)).toList())),
|
||||
"POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||
LoginRequest request = credentials(exchange, body);
|
||||
if (request == null || !acceptable(exchange, request.username(), request.password())) {
|
||||
return;
|
||||
}
|
||||
if (auth.addUser(session.user(), request.username(), request.password().toCharArray())) {
|
||||
ApiServer.send(exchange, 200, Map.of("username", request.username()));
|
||||
} else {
|
||||
ApiServer.send(exchange, 409, Map.of("error", "exists"));
|
||||
}
|
||||
}))));
|
||||
}
|
||||
|
||||
void password(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||
PasswordRequest request;
|
||||
try {
|
||||
request = JSON.readValue(body, PasswordRequest.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
request = null;
|
||||
}
|
||||
if (request == null || request.current() == null || request.next() == null
|
||||
|| request.current().length() > MAX_CREDENTIAL_LENGTH
|
||||
|| request.next().length() > MAX_CREDENTIAL_LENGTH) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "expected {\"current\", \"new\"}"));
|
||||
} else if (!Authentication.LOCAL.equals(session.method())) {
|
||||
ApiServer.send(exchange, 403, Map.of("error", "local_only"));
|
||||
} else if (!Authentication.strongEnough(request.next())) {
|
||||
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
|
||||
} else {
|
||||
var result = auth.changePassword(session.user(), request.current().toCharArray(),
|
||||
request.next().toCharArray(), ClientIp.of(exchange, auth.proxyHops()));
|
||||
switch (result.status()) {
|
||||
case OK -> exchange.sendResponseHeaders(204, -1);
|
||||
case INVALID -> ApiServer.send(exchange, 401, Map.of("error", "invalid"));
|
||||
case THROTTLED -> throttled(exchange, result);
|
||||
}
|
||||
}
|
||||
}))));
|
||||
}
|
||||
|
||||
void deleteUser(HttpExchange exchange) throws IOException {
|
||||
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
|
||||
var path = DELETE_PATH.matcher(exchange.getRequestURI().getRawPath());
|
||||
if (!path.matches()) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
String name = path.group(1);
|
||||
switch (auth.removeUser(session.user(), name)) {
|
||||
case UNKNOWN -> ApiServer.send(exchange, 404, Map.of("error", "unknown_user"));
|
||||
case LAST -> ApiServer.send(exchange, 409, Map.of("error", "last_user"));
|
||||
case REMOVED -> {
|
||||
if (name.equals(session.user())) {
|
||||
exchange.getResponseHeaders().add("Set-Cookie", auth.clearedSessionCookie());
|
||||
}
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
}
|
||||
}
|
||||
}))));
|
||||
}
|
||||
|
||||
private static Map<String, String> user(Session session) {
|
||||
return Map.of("user", session.user(), "method", session.method());
|
||||
}
|
||||
|
||||
private static void redirectWithError(HttpExchange exchange, OidcException e) throws IOException {
|
||||
System.err.println("home-pump-backend: OIDC sign-in failed: " + e.getMessage());
|
||||
redirect(exchange, "/#login?error=" + e.reason().code());
|
||||
}
|
||||
|
||||
private static void redirect(HttpExchange exchange, String location) throws IOException {
|
||||
exchange.getResponseHeaders().set("Location", location);
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.sendResponseHeaders(302, -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The client's address for the login throttle. Each trusted proxy appends the address it saw to
|
||||
* X-Forwarded-For, so the entry {@code hops} from the right is the first one no trusted proxy vouches for
|
||||
* beyond that; anything the client put in front of it is ignored. Without enough entries the request did not
|
||||
* come through the proxies, and the socket's address is used.
|
||||
*/
|
||||
final class ClientIp {
|
||||
|
||||
private ClientIp() {
|
||||
}
|
||||
|
||||
static String of(HttpExchange exchange, int hops) {
|
||||
List<String> values = exchange.getRequestHeaders().get("X-Forwarded-For");
|
||||
if (values != null) {
|
||||
List<String> chain = Arrays.stream(String.join(",", values).split(",")).map(String::trim).toList();
|
||||
if (chain.size() >= hops && !chain.get(chain.size() - hops).isEmpty()) {
|
||||
return chain.get(chain.size() - hops);
|
||||
}
|
||||
}
|
||||
return exchange.getRemoteAddress().getAddress().getHostAddress();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.sun.net.httpserver.Filter;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import home.backend.adapters.auth.Authentication;
|
||||
import home.backend.adapters.auth.Session;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Lets a request through only with a valid session, and hands the user's name on to the handlers. */
|
||||
final class SessionFilter extends Filter {
|
||||
|
||||
static final String USER_ATTRIBUTE = "user";
|
||||
|
||||
private final Authentication auth;
|
||||
|
||||
SessionFilter(Authentication auth) {
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
|
||||
Optional<Session> session = auth.session(cookies(exchange));
|
||||
if (session.isPresent()) {
|
||||
exchange.setAttribute(USER_ATTRIBUTE, session.get().user());
|
||||
chain.doFilter(exchange);
|
||||
} else {
|
||||
try (exchange) {
|
||||
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String cookies(HttpExchange exchange) {
|
||||
var values = exchange.getRequestHeaders().get("Cookie");
|
||||
return values == null ? null : String.join("; ", values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "session check";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.AuditRepository;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The gateway's audit measurements (S3) read from InfluxDB; never written. */
|
||||
public final class InfluxAuditRepository implements AuditRepository {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxAuditRepository(InfluxSettings settings) {
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) throws IOException {
|
||||
return rows("Dom_Pompa", "setpoint_audit", "setpoint", from, to);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) throws IOException {
|
||||
return rows("electricity", "deye_settings_audit", "register", from, to);
|
||||
}
|
||||
|
||||
private List<Row> rows(String database, String measurement, String targetTag, Instant from, Instant to)
|
||||
throws IOException {
|
||||
String query = "SELECT * FROM \"" + measurement + "\" WHERE time >= " + from.toEpochMilli()
|
||||
+ "ms AND time < " + to.toEpochMilli() + "ms ORDER BY time DESC";
|
||||
var rows = new ArrayList<Row>();
|
||||
for (JsonNode result : results(influx.query(database, query))) {
|
||||
for (JsonNode series : result.path("series")) {
|
||||
for (JsonNode values : series.path("values")) {
|
||||
rows.add(row(series.path("columns"), values, targetTag));
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static JsonNode results(String body) throws IOException {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = JSON.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
}
|
||||
failOnError(root);
|
||||
for (JsonNode result : root.path("results")) {
|
||||
failOnError(result);
|
||||
}
|
||||
return root.path("results");
|
||||
}
|
||||
|
||||
private static void failOnError(JsonNode node) throws IOException {
|
||||
if (node.hasNonNull("error")) {
|
||||
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||
}
|
||||
}
|
||||
|
||||
private static Row row(JsonNode columns, JsonNode values, String targetTag) {
|
||||
Map<String, JsonNode> byName = new HashMap<>();
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
byName.put(columns.get(i).asText(), values.get(i));
|
||||
}
|
||||
return new Row(Instant.ofEpochMilli(byName.get("time").asLong()), text(byName.get(targetTag)),
|
||||
text(byName.get("requested_by")), text(byName.get("status")),
|
||||
number(byName.get("old_value")), number(byName.get("new_value")));
|
||||
}
|
||||
|
||||
private static String text(JsonNode node) {
|
||||
return node == null || node.isNull() ? "" : node.asText();
|
||||
}
|
||||
|
||||
private static Double number(JsonNode node) {
|
||||
return node == null || !node.isNumber() ? null : node.asDouble();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Read-only queries to InfluxDB 1.x over its HTTP {@code /query} endpoint; times come back in epoch ms. */
|
||||
final class InfluxClient {
|
||||
|
||||
private final InfluxSettings settings;
|
||||
private final HttpClient http;
|
||||
|
||||
InfluxClient(InfluxSettings settings) {
|
||||
this.settings = settings;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
||||
}
|
||||
|
||||
String query(String database, String query) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
|
||||
.timeout(settings.requestTimeout())
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private URI queryUri(String database, String query) {
|
||||
String base = settings.baseUri().toString().replaceAll("/+$", "");
|
||||
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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.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, HeatPumpSamples {
|
||||
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxHistoryRepository(InfluxSettings settings) {
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException {
|
||||
if (maxPoints < 1 || !from.isBefore(to)) {
|
||||
throw new IllegalArgumentException("need maxPoints >= 1 and from < to");
|
||||
}
|
||||
long stepMinutes = stepMinutes(from, to, maxPoints);
|
||||
Map<Long, Double> summed = new TreeMap<>();
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -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,50 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** The {@code time, mean} rows of an InfluxDB 1.x query response. */
|
||||
final class InfluxResponse {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private InfluxResponse() {
|
||||
}
|
||||
|
||||
/** Mean per bucket start in epoch milliseconds, ascending; empty when there is no data. */
|
||||
static Map<Long, Double> parse(String body) throws IOException {
|
||||
JsonNode root = read(body);
|
||||
failOnError(root);
|
||||
Map<Long, Double> means = new TreeMap<>();
|
||||
for (JsonNode result : root.path("results")) {
|
||||
failOnError(result);
|
||||
for (JsonNode series : result.path("series")) {
|
||||
for (JsonNode row : series.path("values")) {
|
||||
if (row.size() >= 2 && row.get(1).isNumber()) {
|
||||
means.put(row.get(0).asLong(), row.get(1).asDouble());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return means;
|
||||
}
|
||||
|
||||
private static JsonNode read(String body) throws IOException {
|
||||
try {
|
||||
return JSON.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void failOnError(JsonNode node) throws IOException {
|
||||
if (node.hasNonNull("error")) {
|
||||
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.Periods;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** The raw {@code heat_pump} rows of an InfluxDB 1.x query. */
|
||||
final class InfluxSamples {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private InfluxSamples() {
|
||||
}
|
||||
|
||||
static String query(long fromMs, long toMs) {
|
||||
return "SELECT output_compressor, output_cwu, output_co, outdoor_temp, cwu_temp,"
|
||||
+ " program, target_room_temp, target_cwu_temp, setpoint_co_economy, setpoint_co_comfort,"
|
||||
+ " setpoint_cwu_economy, setpoint_cwu_comfort"
|
||||
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
|
||||
}
|
||||
|
||||
/** The rows of {@link #query}, as returned (ascending); empty when there is no data. */
|
||||
static List<HeatPumpSample> parse(String body) throws IOException {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = JSON.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
}
|
||||
failOnError(root);
|
||||
List<HeatPumpSample> samples = new ArrayList<>();
|
||||
for (JsonNode result : root.path("results")) {
|
||||
failOnError(result);
|
||||
for (JsonNode series : result.path("series")) {
|
||||
for (JsonNode row : series.path("values")) {
|
||||
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
|
||||
number(row, 1), number(row, 2), number(row, 3),
|
||||
number(row, 4), number(row, 5), periods(row)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
private static Periods periods(JsonNode row) {
|
||||
var targets = new Targets(number(row, 7), null, number(row, 8));
|
||||
var setpoints = new Setpoints(whole(row, 9), whole(row, 10), whole(row, 11), whole(row, 12));
|
||||
return Periods.of(whole(row, 6), targets, setpoints);
|
||||
}
|
||||
|
||||
private static Integer whole(JsonNode row, int column) {
|
||||
Double value = number(row, column);
|
||||
return value == null ? null : (int) Math.round(value);
|
||||
}
|
||||
|
||||
private static Double number(JsonNode row, int column) {
|
||||
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
|
||||
}
|
||||
|
||||
private static void failOnError(JsonNode node) throws IOException {
|
||||
if (node.hasNonNull("error")) {
|
||||
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
|
||||
public record InfluxSettings(URI baseUri, Duration requestTimeout) {
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
|
||||
final class SeriesSources {
|
||||
|
||||
static final String LEGACY = "Dom_Pompa";
|
||||
private static final String ELECTRICITY = "electricity";
|
||||
|
||||
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
|
||||
Map.entry(Series.OUTDOOR_TEMP, List.of(pompa("External temperature"))),
|
||||
Map.entry(Series.INDOOR_TEMP, List.of(pompa("Temp wew"))),
|
||||
Map.entry(Series.CO_BUFFER_TEMP, List.of(pompa("Temp co"))),
|
||||
Map.entry(Series.CWU_TEMP, List.of(pompa("Temp cwu"))),
|
||||
Map.entry(Series.GROUND_IN_TEMP, List.of(pompa("Temp in"))),
|
||||
Map.entry(Series.GROUND_OUT_TEMP, List.of(pompa("Temp out"))),
|
||||
Map.entry(Series.PUMP_OUTLET_TEMP, List.of(pompa("Temp pc out"))),
|
||||
Map.entry(Series.GRID_POWER, List.of(electricity("PRAD2", "total_power_active"))),
|
||||
Map.entry(Series.PV_POWER, List.of(
|
||||
electricity("deye", "pv1_power_w"),
|
||||
electricity("deye", "pv2_power_w"),
|
||||
electricity("fronius", "pv_power_w"))),
|
||||
Map.entry(Series.LOAD_POWER, List.of(electricity("deye", "load_power_w"))),
|
||||
Map.entry(Series.BATTERY_SOC, List.of(electricity("deye", "battery_soc_pct"))));
|
||||
|
||||
private SeriesSources() {
|
||||
}
|
||||
|
||||
static List<Source> of(Series series) {
|
||||
return SOURCES.get(series);
|
||||
}
|
||||
|
||||
private static Source pompa(String field) {
|
||||
return new Source(LEGACY, "POMPA", field);
|
||||
}
|
||||
|
||||
private static Source electricity(String measurement, String field) {
|
||||
return new Source(ELECTRICITY, measurement, field);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
/** One field of one measurement; the parts of a series are summed. */
|
||||
record Source(String database, String measurement, String field) {
|
||||
|
||||
String query(long fromMs, long toMs, long stepMinutes) {
|
||||
return "SELECT mean(" + quote(field) + ") FROM " + quote(measurement)
|
||||
+ " WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms"
|
||||
+ " GROUP BY time(" + stepMinutes + "m) fill(none)";
|
||||
}
|
||||
|
||||
private static String quote(String identifier) {
|
||||
return '"' + identifier.replace("\\", "\\\\").replace("\"", "\\\"") + '"';
|
||||
}
|
||||
}
|
||||
@@ -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,93 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.StatsView.Day;
|
||||
import home.backend.domain.StatsView.Totals;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** What the heat pump did over some span of polls; days are added up into the totals. */
|
||||
final class DayTally {
|
||||
|
||||
private double compressorSeconds;
|
||||
private double cwuSeconds;
|
||||
private double coSeconds;
|
||||
private int starts;
|
||||
private int samples;
|
||||
private double outdoorSum;
|
||||
private int outdoorCount;
|
||||
private Double minCwu;
|
||||
private Double maxCwu;
|
||||
|
||||
void sample(HeatPumpSample s) {
|
||||
samples++;
|
||||
if (s.outdoorTemp() != null) {
|
||||
outdoorSum += s.outdoorTemp();
|
||||
outdoorCount++;
|
||||
}
|
||||
if (s.cwuTemp() != null) {
|
||||
minCwu = lower(minCwu, s.cwuTemp());
|
||||
maxCwu = higher(maxCwu, s.cwuTemp());
|
||||
}
|
||||
}
|
||||
|
||||
/** The outputs of {@code s} held for {@code held}. */
|
||||
void hold(HeatPumpSample s, Duration held) {
|
||||
double seconds = held.toMillis() / 1000.0;
|
||||
compressorSeconds += on(s.compressor()) ? seconds : 0;
|
||||
cwuSeconds += on(s.cwu()) ? seconds : 0;
|
||||
coSeconds += on(s.co()) ? seconds : 0;
|
||||
}
|
||||
|
||||
void start() {
|
||||
starts++;
|
||||
}
|
||||
|
||||
void add(DayTally other) {
|
||||
compressorSeconds += other.compressorSeconds;
|
||||
cwuSeconds += other.cwuSeconds;
|
||||
coSeconds += other.coSeconds;
|
||||
starts += other.starts;
|
||||
samples += other.samples;
|
||||
outdoorSum += other.outdoorSum;
|
||||
outdoorCount += other.outdoorCount;
|
||||
if (other.minCwu != null) {
|
||||
minCwu = lower(minCwu, other.minCwu);
|
||||
maxCwu = higher(maxCwu, other.maxCwu);
|
||||
}
|
||||
}
|
||||
|
||||
Day day(LocalDate date) {
|
||||
if (samples == 0) {
|
||||
return new Day(date, null, null, null, null, null, null, null);
|
||||
}
|
||||
return new Day(date, hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
||||
avgOutdoor(), minCwu, maxCwu);
|
||||
}
|
||||
|
||||
Totals totals() {
|
||||
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
||||
avgOutdoor(), minCwu, maxCwu);
|
||||
}
|
||||
|
||||
static boolean on(Double output) {
|
||||
return output != null && output > 0;
|
||||
}
|
||||
|
||||
private static Double lower(Double known, double value) {
|
||||
return known == null ? value : Math.min(known, value);
|
||||
}
|
||||
|
||||
private static Double higher(Double known, double value) {
|
||||
return known == null ? value : Math.max(known, value);
|
||||
}
|
||||
|
||||
private Double avgOutdoor() {
|
||||
return outdoorCount == 0 ? null : Math.round(outdoorSum / outdoorCount * 10) / 10.0;
|
||||
}
|
||||
|
||||
private static double hours(double seconds) {
|
||||
return Math.round(seconds / 3600 * 100) / 100.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/** Polish name and unit of the Deye program registers and of the app's reserve, as the gateway audits them. */
|
||||
final class DeyeSettingLabels {
|
||||
|
||||
record Label(String what, String unit) {
|
||||
}
|
||||
|
||||
private static final int TOU_ENABLE = 146;
|
||||
private static final int FIRST_START = 148;
|
||||
private static final int FIRST_SOC = 166;
|
||||
private static final int FIRST_GRID_CHARGE = 172;
|
||||
private static final int SLOTS = 6;
|
||||
|
||||
private DeyeSettingLabels() {
|
||||
}
|
||||
|
||||
static Label of(String register) {
|
||||
if ("reserve".equals(register)) {
|
||||
return new Label("Rezerwa baterii", "%");
|
||||
}
|
||||
int address;
|
||||
try {
|
||||
address = Integer.parseInt(register);
|
||||
} catch (NumberFormatException e) {
|
||||
return unknown(register);
|
||||
}
|
||||
if (address == TOU_ENABLE) {
|
||||
return new Label("Harmonogram baterii", "0/1");
|
||||
}
|
||||
return slot(address, FIRST_START, "start", "HHMM")
|
||||
.or(() -> slot(address, FIRST_SOC, "poziom", "%"))
|
||||
.or(() -> slot(address, FIRST_GRID_CHARGE, "ładowanie z sieci", "0/1"))
|
||||
.orElseGet(() -> unknown(register));
|
||||
}
|
||||
|
||||
private static Label unknown(String register) {
|
||||
return new Label("Rejestr " + register, "");
|
||||
}
|
||||
|
||||
private static Optional<Label> slot(int address, int first, String what, String unit) {
|
||||
return address >= first && address < first + SLOTS
|
||||
? Optional.of(new Label("Przedział " + (address - first + 1) + ": " + what, unit))
|
||||
: Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -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("Wyjście zapasowe falownika",
|
||||
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", "Wyjście zapasowe", "kWh", 1),
|
||||
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
|
||||
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
|
||||
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
|
||||
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
|
||||
group("Od początku",
|
||||
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
|
||||
field("load_total_kwh", "Wyjście zapasowe", "kWh", 0),
|
||||
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
|
||||
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
|
||||
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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 java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* F7: house electricity from the Deye inverter, the Fronius PV inverter and the house meter.
|
||||
* The Deye "load" registers cover only its backup output, so the house is a balance:
|
||||
* <ul>
|
||||
* <li>now = Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, where grid is the
|
||||
* house meter's total active power when it is live, else the Deye's grid power; floored at 0, null when
|
||||
* any of those readings is missing or stale;</li>
|
||||
* <li>today = Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged
|
||||
* (Deye day counters), null when any is missing.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class EnergyService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public EnergyService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public EnergyView current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
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();
|
||||
Map<String, Double> meter = SnapshotFields.live(snapshot, "house-meter", "PRAD2", clock.instant()).fields();
|
||||
|
||||
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
|
||||
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
|
||||
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), houseKwh(d, froniusKwh));
|
||||
List<Source> sources = List.of(deye.source(), fronius.source());
|
||||
return new EnergyView(oldest(sources), sources,
|
||||
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
|
||||
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
|
||||
housePowerW(d, f, meter.getOrDefault("total_power_active", d.get("grid_power_w"))),
|
||||
d.get("load_power_w"), today);
|
||||
}
|
||||
|
||||
private static Double housePowerW(Map<String, Double> deye, Map<String, Double> fronius, Double gridW) {
|
||||
Double pv1 = deye.get("pv1_power_w");
|
||||
Double pv2 = deye.get("pv2_power_w");
|
||||
Double pvFronius = fronius.get("pv_power_w");
|
||||
Double battery = deye.get("battery_power_w");
|
||||
if (Stream.of(pv1, pv2, pvFronius, battery, gridW).anyMatch(Objects::isNull)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, pv1 + pv2 + pvFronius + battery + gridW);
|
||||
}
|
||||
|
||||
private static Double houseKwh(Map<String, Double> deye, Double froniusKwh) {
|
||||
Double pv = deye.get("pv_today_kwh");
|
||||
Double bought = deye.get("grid_bought_today_kwh");
|
||||
Double sold = deye.get("grid_sold_today_kwh");
|
||||
Double discharged = deye.get("battery_discharge_today_kwh");
|
||||
Double charged = deye.get("battery_charge_today_kwh");
|
||||
if (Stream.of(pv, froniusKwh, bought, sold, discharged, charged).anyMatch(Objects::isNull)) {
|
||||
return null;
|
||||
}
|
||||
return round(Math.max(0, pv + froniusKwh + bought - sold + discharged - charged));
|
||||
}
|
||||
|
||||
private static Instant oldest(List<Source> sources) {
|
||||
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
/** Sum of the values that exist; null when none does. */
|
||||
private static Double sum(Double... values) {
|
||||
return Stream.of(values).filter(v -> v != null).reduce(Double::sum).orElse(null);
|
||||
}
|
||||
|
||||
/** Wh to kWh leaves binary noise such as 34.300000000000004. */
|
||||
private static Double round(Double kwh) {
|
||||
return kwh == null ? null : Math.round(kwh * 1000) / 1000.0;
|
||||
}
|
||||
}
|
||||
@@ -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,11 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** The live state, read from the gateway on the Pi through the tunnel. */
|
||||
public interface GatewayClient {
|
||||
|
||||
GatewaySnapshot snapshot() 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;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
|
||||
/** F5: whether the gateway answers and which device links are up. */
|
||||
public class HealthService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
private final String version;
|
||||
|
||||
public HealthService(GatewayClient gateway, Clock clock, String version) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public HealthView current() {
|
||||
try {
|
||||
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(), version, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static DeviceHealth health(DeviceSnapshot d) {
|
||||
return new DeviceHealth(d.name(), d.up(), d.lastSuccess(), d.lastError());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** The heat pump polls stored in InfluxDB, oldest first. */
|
||||
public interface HeatPumpSamples {
|
||||
|
||||
List<HeatPumpSample> samples(Instant from, Instant to) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.HeatPumpView.Temperatures;
|
||||
import home.backend.domain.Periods;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
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;
|
||||
|
||||
/** F1: the heat pump's live state from the gateway's {@code heat_pump} measurement. */
|
||||
public class HeatPumpService {
|
||||
|
||||
private static final String DATABASE = "Dom_Pompa";
|
||||
private static final State[] STATES = State.values();
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public HeatPumpService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public HeatPumpView current() throws IOException {
|
||||
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, boolean stale, Map<String, Double> f) {
|
||||
Integer alarmCode = whole(f, "alarm_code");
|
||||
var targets = new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
|
||||
tenths(f, "target_cwu_temp"));
|
||||
var setpoints = new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
|
||||
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort"));
|
||||
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"),
|
||||
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
|
||||
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
|
||||
on(f, "output_ground"), on(f, "output_cwu")),
|
||||
new Temperatures(tenths(f, "outdoor_temp"), tenths(f, "indoor_temp"),
|
||||
tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
|
||||
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
|
||||
tenths(f, "outdoor_avg_temp")),
|
||||
targets, setpoints, Periods.of(whole(f, "program"), targets, setpoints));
|
||||
}
|
||||
|
||||
/** 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);
|
||||
return value == null ? null : Math.round(value * 10) / 10.0;
|
||||
}
|
||||
|
||||
private static State state(Map<String, Double> f) {
|
||||
Integer mode = whole(f, "pump_mode");
|
||||
return mode == null || mode < 0 || mode >= STATES.length ? null : STATES[mode];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Past values from InfluxDB, averaged into about {@code maxPoints} steps. */
|
||||
public interface HistoryRepository {
|
||||
|
||||
List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HistoryView;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/** F2: one series over the last 24 hours or 7 days. */
|
||||
public class HistoryService {
|
||||
|
||||
static final int MAX_POINTS = 300;
|
||||
private static final String DEFAULT_RANGE = "24h";
|
||||
private static final Map<String, Duration> RANGES =
|
||||
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7));
|
||||
|
||||
private final HistoryRepository repository;
|
||||
private final Clock clock;
|
||||
|
||||
public HistoryService(HistoryRepository repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param range {@code 24h} or {@code 7d}; null means 24h
|
||||
* @throws IllegalArgumentException for an unknown series or range
|
||||
*/
|
||||
public HistoryView history(String seriesId, String range) throws IOException {
|
||||
Series series = Series.fromId(seriesId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("unknown series: " + seriesId));
|
||||
Duration length = RANGES.get(range == null ? DEFAULT_RANGE : range);
|
||||
if (length == null) {
|
||||
throw new IllegalArgumentException("unknown range: " + range);
|
||||
}
|
||||
Instant to = clock.instant();
|
||||
return new HistoryView(series.id(), series.unit(),
|
||||
repository.history(series, to.minus(length), to, MAX_POINTS));
|
||||
}
|
||||
}
|
||||
@@ -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,193 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.Period;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.ProgramView;
|
||||
import home.backend.domain.ProgramView.Circuit;
|
||||
import home.backend.domain.ProgramView.Confidence;
|
||||
import home.backend.domain.ProgramView.Interval;
|
||||
import home.backend.domain.ProgramView.Weekday;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* F6: the weekly program as the history shows it, without asking the controller (its program
|
||||
* registers sit behind a shared selector, so reading them would mean writing). A transition between
|
||||
* two adjacent polls, economy to comfort or back, puts a comfort boundary halfway between them; a
|
||||
* poll in another period (override, or setpoints that make the periods ambiguous, for instance while
|
||||
* we write one) breaks the chain, as does a gap in the data. Times are local, so a day with a change
|
||||
* of time needs no special case. Today is left out: it is not over.
|
||||
*/
|
||||
public class ProgramService {
|
||||
|
||||
private static final int DEFAULT_DAYS = 14;
|
||||
private static final int MIN_DAYS = 7;
|
||||
private static final int MAX_DAYS = 30;
|
||||
private static final Duration MAX_GAP = StatsService.MAX_HOLD;
|
||||
private static final int STEP_MINUTES = 5;
|
||||
private static final int MINUTES_PER_DAY = 24 * 60;
|
||||
|
||||
private record Span(int from, int to) {
|
||||
}
|
||||
|
||||
/** What the history says about one circuit: days with a known period and their comfort spans. */
|
||||
private record Observed(Set<LocalDate> days, Map<LocalDate, List<Span>> comfort) {
|
||||
}
|
||||
|
||||
private final HeatPumpSamples repository;
|
||||
private final Clock clock;
|
||||
|
||||
public ProgramService(HeatPumpSamples repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param days how many past days to read, 7 to 30; null means 14
|
||||
* @throws IllegalArgumentException for anything else
|
||||
*/
|
||||
public ProgramView program(String days) throws IOException {
|
||||
int length = length(days);
|
||||
LocalDate today = clock.instant().atZone(StatsService.ZONE).toLocalDate();
|
||||
List<HeatPumpSample> samples = repository.samples(
|
||||
today.minusDays(length).atStartOfDay(StatsService.ZONE).toInstant(),
|
||||
today.atStartOfDay(StatsService.ZONE).toInstant());
|
||||
Observed cwu = observe(samples, Periods::cwu);
|
||||
Observed room = observe(samples, Periods::room);
|
||||
List<Weekday> weekdays = new ArrayList<>();
|
||||
for (int day = 1; day <= 7; day++) {
|
||||
weekdays.add(new Weekday(day, circuit(cwu, day), circuit(room, day)));
|
||||
}
|
||||
return new ProgramView(length, weekdays);
|
||||
}
|
||||
|
||||
private static int length(String days) {
|
||||
if (days == null) {
|
||||
return DEFAULT_DAYS;
|
||||
}
|
||||
try {
|
||||
int length = Integer.parseInt(days);
|
||||
if (length >= MIN_DAYS && length <= MAX_DAYS) {
|
||||
return length;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// reported below
|
||||
}
|
||||
throw new IllegalArgumentException("days must be " + MIN_DAYS + " to " + MAX_DAYS);
|
||||
}
|
||||
|
||||
private static Observed observe(List<HeatPumpSample> samples, Function<Periods, Period> circuit) {
|
||||
Set<LocalDate> days = new TreeSet<>();
|
||||
Map<LocalDate, List<Span>> comfort = new TreeMap<>();
|
||||
Period previous = null;
|
||||
Instant previousTime = null;
|
||||
Instant comfortStart = null;
|
||||
for (HeatPumpSample sample : samples) {
|
||||
Period period = circuit.apply(sample.periods());
|
||||
if (period != Period.COMFORT && period != Period.ECONOMY) {
|
||||
previous = null;
|
||||
comfortStart = null;
|
||||
continue;
|
||||
}
|
||||
days.add(sample.t().atZone(StatsService.ZONE).toLocalDate());
|
||||
boolean adjacent = previous != null && Duration.between(previousTime, sample.t()).compareTo(MAX_GAP) <= 0;
|
||||
if (!adjacent) {
|
||||
comfortStart = null;
|
||||
} else if (period != previous) {
|
||||
Instant boundary = previousTime.plus(Duration.between(previousTime, sample.t()).dividedBy(2));
|
||||
if (period == Period.COMFORT) {
|
||||
comfortStart = boundary;
|
||||
} else if (comfortStart != null) {
|
||||
addSpans(comfort, comfortStart, boundary);
|
||||
comfortStart = null;
|
||||
}
|
||||
}
|
||||
previous = period;
|
||||
previousTime = sample.t();
|
||||
}
|
||||
return new Observed(days, comfort);
|
||||
}
|
||||
|
||||
/** Splits at local midnight; a start at 23:58 and an end at 00:03 are rounded before that. */
|
||||
private static void addSpans(Map<LocalDate, List<Span>> comfort, Instant from, Instant to) {
|
||||
LocalDateTime start = rounded(from.atZone(StatsService.ZONE).toLocalDateTime());
|
||||
LocalDateTime end = rounded(to.atZone(StatsService.ZONE).toLocalDateTime());
|
||||
for (LocalDate date = start.toLocalDate(); !date.isAfter(end.toLocalDate()); date = date.plusDays(1)) {
|
||||
int spanStart = date.equals(start.toLocalDate()) ? minutes(start) : 0;
|
||||
int spanEnd = date.equals(end.toLocalDate()) ? minutes(end) : MINUTES_PER_DAY;
|
||||
if (spanEnd > spanStart) {
|
||||
comfort.computeIfAbsent(date, d -> new ArrayList<>()).add(new Span(spanStart, spanEnd));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDateTime rounded(LocalDateTime time) {
|
||||
long steps = Math.round((time.getMinute() * 60 + time.getSecond()) / (STEP_MINUTES * 60.0));
|
||||
return time.truncatedTo(ChronoUnit.HOURS).plusMinutes(steps * STEP_MINUTES);
|
||||
}
|
||||
|
||||
private static int minutes(LocalDateTime time) {
|
||||
return time.getHour() * 60 + time.getMinute();
|
||||
}
|
||||
|
||||
/**
|
||||
* The pattern most days of this weekday agree on (the latest wins a tie); {@code CONFIRMED} when at
|
||||
* least two days show it.
|
||||
*/
|
||||
private static Circuit circuit(Observed observed, int weekday) {
|
||||
List<List<Span>> patterns = observed.days().stream()
|
||||
.filter(date -> date.getDayOfWeek().getValue() == weekday)
|
||||
.map(date -> observed.comfort().getOrDefault(date, List.of()))
|
||||
.toList();
|
||||
if (patterns.isEmpty()) {
|
||||
return new Circuit(Confidence.NO_DATA, 0, 0, List.of());
|
||||
}
|
||||
List<Span> best = null;
|
||||
int bestCount = 0;
|
||||
for (List<Span> pattern : patterns) {
|
||||
int count = (int) patterns.stream().filter(other -> agree(pattern, other)).count();
|
||||
if (count >= bestCount) {
|
||||
best = pattern;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return new Circuit(bestCount >= 2 ? Confidence.CONFIRMED : Confidence.SEEN_ONCE, patterns.size(), bestCount,
|
||||
best.stream().map(ProgramService::interval).toList());
|
||||
}
|
||||
|
||||
/** Equal within one step at both ends of every span. */
|
||||
private static boolean agree(List<Span> a, List<Span> b) {
|
||||
if (a.size() != b.size()) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < a.size(); i++) {
|
||||
if (Math.abs(a.get(i).from() - b.get(i).from()) > STEP_MINUTES
|
||||
|| Math.abs(a.get(i).to() - b.get(i).to()) > STEP_MINUTES) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Interval interval(Span span) {
|
||||
return new Interval(hhmm(span.from()), hhmm(span.to()));
|
||||
}
|
||||
|
||||
private static String hhmm(int minutes) {
|
||||
return String.format("%02d:%02d", minutes / 60, minutes % 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/** What the panels produced, from InfluxDB. */
|
||||
public interface PvEnergyRepository {
|
||||
|
||||
/** Deye plus Fronius kWh per local day from {@code from} up to {@code to}; days missing either are absent. */
|
||||
Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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 {
|
||||
|
||||
/** Longest timed preset the user may choose; the gateway's own limit is higher. */
|
||||
public static final int MAX_PRESET_HOURS = 4;
|
||||
|
||||
/** The chosen hours are not a whole number from 1 to {@link #MAX_PRESET_HOURS}, or the preset is not timed. */
|
||||
public static class InvalidHoursException extends IllegalArgumentException {
|
||||
InvalidHoursException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
private final GatewayCommands gateway;
|
||||
private final List<Preset> presets;
|
||||
|
||||
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 hours how long a timed preset keeps its changes, 1 to {@link #MAX_PRESET_HOURS}; null keeps the preset's
|
||||
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
|
||||
* preset's value
|
||||
* @throws InvalidHoursException when {@code hours} is out of range or the preset has no hours to choose
|
||||
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
|
||||
* for a setpoint the preset does not change or outside the gateway's limits
|
||||
*/
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, Integer hours, Map<String, Integer> values,
|
||||
String user)
|
||||
throws IOException {
|
||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||
if (preset.isEmpty()) {
|
||||
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 = withHours(withValues(preset.get().changes(), values), hours);
|
||||
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||
requireWithinLimits(values, limits);
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
|
||||
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 List<Change> withHours(List<Change> changes, Integer hours) {
|
||||
if (hours == null) {
|
||||
return changes;
|
||||
}
|
||||
if (hours < 1 || hours > MAX_PRESET_HOURS) {
|
||||
throw new InvalidHoursException("hours must be 1 to " + MAX_PRESET_HOURS);
|
||||
}
|
||||
if (changes.stream().anyMatch(c -> c.hours() == null)) {
|
||||
throw new InvalidHoursException("this preset has no hours to choose");
|
||||
}
|
||||
return changes.stream().map(c -> new Change(c.setpoint(), c.value(), hours.doubleValue())).toList();
|
||||
}
|
||||
|
||||
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
|
||||
for (Setpoint limit : limits) {
|
||||
Integer value = values.get(limit.name());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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;
|
||||
|
||||
/** Looks up one measurement of one device in a snapshot and reads typed fields. */
|
||||
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))
|
||||
.map(DeviceSnapshot::measurements)
|
||||
.flatMap(List::stream)
|
||||
.filter(m -> m.measurement().equals(measurement))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
static Integer whole(Map<String, Double> fields, String name) {
|
||||
Double value = fields.get(name);
|
||||
return value == null ? null : (int) Math.round(value);
|
||||
}
|
||||
|
||||
static boolean on(Map<String, Double> fields, String name) {
|
||||
Double value = fields.get(name);
|
||||
return value != null && value != 0;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Loaded 100 of 475 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user