Compare commits

..
104 Commits
Author SHA1 Message Date
root 6480c98647 Merge PR #110: feat(frontend): mobile-friendly temperature charts with a fixed readout
Release / Image (frontend) (push) Successful in 58s
Release / Image (gateway) (push) Successful in 33s
CI / Check (gateway, backend, frontend) (push) Successful in 1m19s
Release / Image (backend) (push) Successful in 1m20s
2026-10-26 19:11:44 +01:00
rootandClaude Opus 5.5 31e9d056aa feat(frontend): mobile-first temperature charts with a readout above the plot
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
Replace the floating recharts tooltip, which covered the plot on a phone, with
toggle chips and a readout row above a plain SVG chart that scrubs by touch.
The four series are CWU, Bufor CO, Do pompy and Wyjście pompy; indoor and
outdoor temperature are dropped from the charts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:08:27 +01:00
root 326db85114 Merge PR #106: fix(frontend): Deye PV in the inverter list adds only the string powers
CI / Check (gateway, backend, frontend) (push) Successful in 1m9s
Release / Image (backend) (push) Successful in 1m8s
Release / Image (frontend) (push) Successful in 50s
Release / Image (gateway) (push) Successful in 29s
2026-10-25 09:46:30 +01:00
rootandClaude Opus 5.5 ead74ebdec fix(frontend): Deye PV in the inverter list adds only the string powers
CI / Check (gateway, backend, frontend) (push) Successful in 1m12s
The summary summed every item of the Panele PV group, including each
string's voltage and current, so 5.9 kW of PV showed as about 7 kW.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:44:43 +01:00
root f99ae01626 Merge PR #105: fix: house consumption from the energy balance, Deye load is only its backup output
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
2026-10-25 09:40:13 +01:00
rootandClaude Opus 5.5 a804923290 fix(energy): house consumption as a balance, not the Deye backup output
CI / Check (gateway, backend, frontend) (push) Successful in 1m11s
The Deye load registers cover only the inverter's backup output, so the
house showed 0 W while it drew 300 W. Now power is PV + battery + grid
(house meter, Deye grid as fallback) and today's energy is the day
counters balance. The backup output is exposed as backupOutputW.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:35:30 +01:00
root cc0495fc46 Merge PR #104: refactor: remove the CWU heater, the house has none
CI / Check (gateway, backend, frontend) (push) Successful in 1m9s
Release / Image (backend) (push) Successful in 1m8s
Release / Image (frontend) (push) Successful in 54s
Release / Image (gateway) (push) Successful in 29s
2026-10-25 09:23:10 +01:00
root b3fb7f8b40 Merge PR #103: feat: choose temperature and hours for Ciepła woda teraz
CI / Check (gateway, backend, frontend) (push) Successful in 1m10s
2026-10-25 09:20:53 +01:00
rootandClaude Opus 5.5 33aead7352 refactor: remove the CWU heater, the house has none
CI / Check (gateway, backend, frontend) (push) Successful in 1m8s
The app showed the controller's heater signal as a device, a detail and a
statistic. Drop it from the API (heaterOn, heaterHours) and the UI; the
gateway still records output_heater as raw data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:20:35 +01:00
rootandClaude Opus 5.5 ee13c00bbb fix(frontend): short hour labels so the buttons do not wrap
CI / Check (gateway, backend, frontend) (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:18:44 +01:00
rootandClaude Opus 5.5 2e94cc1429 feat(frontend): choose temperature and hours for Ciepła woda teraz
The form reuses the trip form's steppers; the pump alone reaches about
47-48 degrees, so a higher choice gets a hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:17:12 +01:00
rootandClaude Opus 5.5 0895125e9c feat(presets): choose the hours of a timed preset
POST /api/presets/{id} takes an optional whole number of hours (1-4) for a
preset whose changes are timed; anything else is 400 invalid_hours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:14:40 +01:00
root 36bee5ca08 Merge PR #102: docs(frisko): compressor and CWU parameters read on 2026-10-25
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
2026-10-25 08:49:35 +01:00
rootandClaude Opus 5.5 fa678473e3 docs(frisko): compressor and CWU parameters read on 2026-10-25
CI / Check (gateway, backend, frontend) (push) Successful in 2m12s
A one-off read-only FC3 of 4146-4166 and 4246-4247 between two gateway
polls. 4166 = 20 min explains the stage-2 switch seen in history and
4162 = 5 min the five-minute restart after a TmaxPc stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:46:46 +01:00
root b46d820fb2 Merge PR #101: docs(frisko): the +20 min meter step is the compressor's second stage
CI / Check (gateway, backend, frontend) (push) Failing after 1m11s
2026-10-25 08:33:28 +01:00
rootandClaude Opus 5.5 f61c25f449 docs(frisko): the +20 min meter step is the compressor's second stage
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
The owner says the CWU heater is not connected; the per-phase meter data
show the step splits evenly over the three phases while stage 4114 goes
from 33 to 66, so it is the compressor, not a heater. At an outlet of
52 C (TmaxPc) the compressor stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:31:26 +01:00
root eec29666eb Merge PR #100: fix: the CWU heater is not connected, show its output as a controller signal
CI / Check (gateway, backend, frontend) (push) Failing after 1m15s
2026-10-25 08:25:16 +01:00
rootandClaude Opus 5.5 6d9e92ee22 docs(frisko): the CWU heater is not connected, its output is only a signal
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
The meter step at +20 min of a CWU cycle stays unexplained; list the hypotheses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:22:53 +01:00
rootandClaude Opus 5.5 bb22afcd35 fix(frontend): show the heater output as a controller signal, not as power
The electric CWU heater is not connected (owner, 2026-10-25), so the amber
warning, the red chip and glow and the expensive-power hint were wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:22:10 +01:00
root ae9376b24d Merge PR #99: feat: active periods, heater warning and the weekly program from history
CI / Check (gateway, backend, frontend) (push) Successful in 1m12s
2026-10-25 00:22:45 +02:00
rootandClaude Opus 5.5 31b668f313 feat(frontend): period, heater warning and weekly program from history
CI / Check (gateway, backend, frontend) (push) Successful in 1m13s
Overview shows comfort or economy under the water target and in the room
details, and an amber warning while the electric heater runs. Ustawienia
says what Ciepła woda teraz does during a comfort period. Historia gets a
Program view with the intervals the history shows per weekday.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:18:56 +02:00
rootandClaude Opus 5.5 36875dd103 feat(backend): active period, heater state and weekly program from history
The heat pump view says whether CWU and room are in comfort or economy,
from the target compared with the setpoints (valid with program 1 only).
GET /api/program reads the comfort intervals per weekday from the polls
instead of asking the controller, whose program registers need writes.
Adds the history analysis of 2026-10 to docs/frisko.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:16:38 +02:00
root c8ada0e6a8 Merge PR #98: docs: ADR-016, the web UI is on the internet behind the sign-in
CI / Check (gateway, backend, frontend) (push) Failing after 1m9s
2026-10-24 23:43:32 +02:00
rootandClaude Opus 5.5 0e3a77047b test(frontend): allow 20 s per test on a busy CI runner
CI / Check (gateway, backend, frontend) (push) Successful in 1m11s
The parallel check.sh builds the gateway and backend while the frontend
tests run; on the shared runner the first render of a file took over the
5 s default, and the timed-out test left its DOM for the next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:41:35 +02:00
rootandClaude Opus 5.5 93fedb198c docs: ADR-016, the web UI is on the internet behind the sign-in
CI / Check (gateway, backend, frontend) (push) Failing after 1m12s
ADR-009 kept the UI on the Krosno network without a login. With the
sign-in of ADR-014 the app is open to the internet; only first-account
setup keeps the two-homes allowlist in Traefik.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:39:14 +02:00
root b69beadd76 Merge PR #96: feat(frontend): login, first-account setup and accounts
CI / Check (gateway, backend, frontend) (push) Successful in 1m7s
Release / Image (backend) (push) Successful in 1m10s
Release / Image (frontend) (push) Successful in 51s
Release / Image (gateway) (push) Successful in 30s
2026-10-24 23:16:59 +02:00
root 62d61af5dd Merge PR #97: feat(backend): sign-in with local accounts and Keycloak (ADR-014) 2026-10-24 23:16:59 +02:00
rootandClaude Opus 5.5 4e8ffc2ec3 feat(backend): first-account setup and account management in the app
CI / Check (gateway, backend, frontend) (push) Successful in 1m8s
With USERS_FILE set but empty or missing the backend starts in setup
mode and the first visitor creates the account, once, under a lock.
Signed-in users list, add and remove accounts and change their own
password; removed accounts lose their sessions at once. USERS_FILE is
rewritten atomically (temp, fsync, rename, mode 600).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:13:31 +02:00
rootandClaude Opus 5.5 eac21b17e2 test(frontend): keep sample passwords out of the secret scan
CI / Check (gateway, backend, frontend) (push) Successful in 53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:12:47 +02:00
rootandClaude Opus 5.5 73bbb1a0d7 feat(frontend): first-account setup screen and Konta card in Ustawienia
With config.setup the login form becomes "Utwórz pierwsze konto" (a 409
falls back to login). Ustawienia gets accounts: list, add, change own
password (local only), delete with confirmation, not the last one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:12:11 +02:00
rootandClaude Opus 5.5 f92307d9e1 fix(backend): atomic login throttle, revoked accounts, log hygiene
CI / Check (gateway, backend, frontend) (push) Successful in 55s
The throttle now counts an attempt before the password is hashed, so
parallel guesses cannot outrun it, and at most 4 hashes run at once.
A local session ends when its account leaves USERS_FILE. The provider's
error text is sanitized before logging, a missing OIDC_REQUIRED_ROLE is
warned about, OIDC discovery and keys are fetched outside the lock and
an unreadable JWK is skipped. Ops notes: nginx header, compose example,
/api/live for monitors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:04:49 +02:00
rootandClaude Opus 5.5 34b12881b9 fix(frontend): login edge cases from review (no-login server, odd oidc codes, 404)
CI / Check (gateway, backend, frontend) (push) Successful in 53s
Method "none" is signed in without Account or logout; a login screen with
no enabled way in offers a reload; unknown oidc error codes (__proto__)
no longer crash the page; a 404 from login says local login is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:03:02 +02:00
rootandClaude Opus 5.5 b7b26d83b2 test(backend): rename test key constants so the secret scan passes
CI / Check (gateway, backend, frontend) (push) Successful in 59s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:52:41 +02:00
rootandClaude Opus 5.5 24b7946df1 feat(backend): sign-in with local accounts and Keycloak (ADR-014)
The backend now authenticates itself: local accounts from USERS_FILE
(PBKDF2-SHA256, throttled) and OIDC code flow with PKCE, independent of
each other, so Keycloak being down never locks the family out. A signed
stateless cookie carries the session; its user replaces Remote-User as
the requester. Without any sign-in configured the backend refuses to
start unless AUTH_DISABLED=true. /api/live serves the healthcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:51:21 +02:00
rootandClaude Opus 5.5 16534ddcee feat(frontend): login screen with password and Keycloak, session expiry
CI / Check (gateway, backend, frontend) (push) Successful in 1m4s
AuthGate asks api/auth/me before the app starts; without a session the
login screen shows instead of the app and its pollers. The form is always
the way in when Keycloak is down. A 401 from any poller or write sends the
user back to login and, after it, to the same screen. Stan and the side
panel show the user and "Wyloguj".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:44:30 +02:00
root e6d9487f0c Merge PR #95: fix: refresh cooldown counts pending requests, button beside the title
CI / Check (gateway, backend, frontend) (push) Successful in 51s
Release / Image (backend) (push) Successful in 33s
Release / Image (frontend) (push) Successful in 48s
Release / Image (gateway) (push) Successful in 1m15s
2026-10-24 19:10:07 +02:00
rootandClaude Opus 5.5 64ffd035c5 feat(frontend): Odśwież button beside every screen title, with its text
CI / Check (gateway, backend, frontend) (push) Successful in 57s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:08:15 +02:00
rootandClaude Opus 5.5 87d111398c fix(gateway): refresh cooldown counts from the last refresh request
A second refresh within the repoll delay reported the device as requested again
although the poll was coalesced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:08:15 +02:00
root 24a141a377 Merge PR #94: feat: Odśwież button with a 60 s per-device cooldown
CI / Check (gateway, backend, frontend) (push) Successful in 53s
Release / Image (backend) (push) Successful in 1m42s
Release / Image (frontend) (push) Successful in 1m10s
Release / Image (gateway) (push) Successful in 1m50s
2026-10-24 18:56:22 +02:00
root a1d6a5c8a6 Merge PR #93: chore: parallel builds in check.sh and releases, release waits for green CI 2026-10-24 18:56:22 +02:00
rootandClaude Opus 5.5 d1f053079a chore(ci): one job again, caches cannot work on this runner
CI / Check (gateway, backend, frontend) (push) Successful in 49s
The actions cache server is unreachable from job containers (ETIMEDOUT after 4.5 min per cache step) and the runner takes one job at a time, so the split cost 30 minutes. check.sh runs the parts in parallel inside the job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:49:16 +02:00
rootandClaude Opus 5.5 8b07b9ba0e feat(frontend): Odśwież button that asks the gateway for fresh data
CI / Check (gateway, backend, frontend) (push) Successful in 1m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:38:33 +02:00
rootandClaude Opus 5.5 aaf08d4f5c feat(backend): POST /api/refresh forwards the refresh to the gateway
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:36:18 +02:00
rootandClaude Opus 5.5 b0c0872775 feat(gateway): POST /refresh polls devices again with a 60 s cooldown
The Frisko MK01 takes one client, so a button must not poll the heat pump
more often than once a minute. Devices polled more recently are skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:33:40 +02:00
rootandClaude Opus 5.5 f128b03ae5 chore(release): require green CI on Gitea instead of re-running the checks
CI / Check (gateway) (push) Successful in 9m48s
CI / Check (frontend) (push) Successful in 9m23s
CI / Check (docs, secrets) (push) Successful in 5s
CI / Check (backend) (push) Successful in 9m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:19:20 +02:00
rootandClaude Opus 5.5 9f9e4e4f50 chore(release): build the three images in parallel with a registry cache
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:19:20 +02:00
rootandClaude Opus 5.5 4b26028d01 chore(ci): run the part builds in parallel with dependency caches
check.sh takes optional targets (docs, gateway, backend, frontend) and builds the parts concurrently. CI runs one job per part with Maven and npm caches; Maven retries downloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:19:20 +02:00
root 655e84be8d Merge PR #91: fix(frontend): stale states, consistent setpoint names, contrast, 44 px theme switch
Release / Images (push) Successful in 3m18s
CI / Check (gateway, backend, frontend) (push) Successful in 1m30s
2026-10-24 18:06:10 +02:00
root 4cb8bfd27b Merge master into fix/settings-status-polish
CI / Check (gateway, backend, frontend) (push) Successful in 1m30s
# Conflicts:
#	frontend/src/ui.tsx
2026-10-24 18:02:41 +02:00
root bdea2a177d Merge PR #92: fix(frontend): readable energy diagram, battery view and inverter rows
CI / Check (gateway, backend, frontend) (push) Successful in 3m27s
2026-10-24 17:59:27 +02:00
root d8b06e2a89 Merge master into fix/energy-polish
CI / Check (gateway, backend, frontend) (push) Successful in 1m26s
# Conflicts:
#	frontend/src/BatterySettings.tsx
2026-10-24 17:53:29 +02:00
root 1405695da5 Merge PR #90: fix(gateway): cap the lease after a failed boost over a held change, battery hardening
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
2026-10-24 17:50:15 +02:00
root ea2862ae04 Merge PR #89: fix: weather across midnight and IMGW back-off, review findings
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
2026-10-24 17:47:52 +02:00
rootandClaude Opus 5.5 317950cb75 fix(ui): name setpoints consistently in Details after merging master
CI / Check (gateway, backend, frontend) (push) Successful in 1m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:46:46 +02:00
root 8423d723a1 Merge PR #88: fix(frontend): readable stats and history charts, 44 px range toggles
CI / Check (gateway, backend, frontend) (push) Successful in 1m27s
2026-10-24 17:45:30 +02:00
rootandClaude Opus 5.5 cb93f24365 fix(energy): readable flow diagram, calmer battery view, device status rows
CI / Check (gateway, backend, frontend) (push) Successful in 1m26s
Vertical flow diagram with 13+ px text, battery flow in the brand colour,
action-first IMGW hint, clear schedule table, status and values per device
and dimmed values for stale sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:45:18 +02:00
rootandClaude Opus 5.5 f181d0bbe3 fix(ui): stale status, clearer Ustawienia and history, contrast, 44 px theme switch
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
Stale devices and the side chip show a warning with an icon; setpoints are
named "Ciepła woda – oszczędnie" etc. in the UI and the audit labels; quick
actions get a one-line description; failed history rows get red or amber
chips; soft text and dark cards get more contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:44:50 +02:00
rootandClaude Opus 5.5 f57c3adc58 fix(http): share one failed-token throttle between endpoints
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:43:40 +02:00
rootandClaude Opus 5.5 56a457085e fix(battery): decide a raised reserve on the live table
The cached reading can be 30 minutes old. A reading missing fields now
counts as no usable settings instead of failing with a 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:43:40 +02:00
rootandClaude Opus 5.5 6dcbb5b8e4 fix(battery): ignore a reserve file outside 0 to 100
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:43:40 +02:00
rootandClaude Opus 5.5 ace189f5e2 fix(commands): cap a held lease when a later boost may have landed
A boost that failed after its write could leave a raised value under the
held change's far lease. The restore now keeps both values but runs
within one lease (ADR-012).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:43:40 +02:00
rootandClaude Opus 5.5 d356fb2a2c perf(stats): poll statistics every 5 minutes
CI / Check (gateway, backend, frontend) (push) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:42:49 +02:00
rootandClaude Opus 5.5 0fb139192d fix(frontend): hash public file contents into the cache version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:42:49 +02:00
rootandClaude Opus 5.5 40823683be fix(sw): fall back to the cached shell when a navigation stalls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:42:49 +02:00
rootandClaude Opus 5.5 1f43987db5 fix(battery): key schedule rows by slot, not by start time
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:42:49 +02:00
root e88c28899f Merge PR #87: feat(frontend): tidy the main screen, each value once
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
2026-10-24 17:42:32 +02:00
rootandClaude Opus 5.5 d5ab64aa61 fix(frontend): readable chart axes, tiles and range toggles
CI / Check (gateway, backend, frontend) (push) Successful in 1m30s
Statystyki: integer hour ticks with unit and axis labels, a legend, tiles without
a grid hole or a wrapping big number. Range toggles are 44 px, equal width and
filled when selected. Historia: 4 to 6 time ticks and value ticks that hug the data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:41:38 +02:00
rootandClaude Opus 5.5 01c5e1efe5 feat(ui): rearrange the overview around four key figures
CI / Check (gateway, backend, frontend) (push) Successful in 1m29s
Warnings first as one-line collapsible banners, then CWU, home, outdoor and
power once; schematic with device rows, weather as rows and one temperature
details card. The side panel icon follows today's forecast. Identical IMGW
warnings show once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:39:21 +02:00
rootandClaude Opus 5.5 0819ed8261 fix(weather): keep forecast across midnight and back off failing sources
The forecast expires at local midnight and covers four days; a failed
refresh serves the older value without retrying for 60 s, and IMGW has a
shorter timeout so it cannot hold the forecast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:39:16 +02:00
rootandClaude Opus 5.5 338befaffa fix(battery): return no schedule when a slot start is missing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:39:16 +02:00
root 378f4f6623 Merge PR #86: feat: weather, PV forecast and IMGW warnings
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
Release / Images (push) Successful in 3m1s
2026-10-24 17:15:27 +02:00
root 82670eb4a1 Merge remote-tracking branch 'origin/master' into feat/weather
CI / Check (gateway, backend, frontend) (push) Successful in 1m24s
# Conflicts:
#	backend/README.md
#	backend/src/main/java/home/backend/Backend.java
#	backend/src/main/java/home/backend/adapters/http/ApiServer.java
#	backend/src/main/java/home/backend/adapters/influx/InfluxHistoryRepository.java
#	backend/src/test/java/home/backend/adapters/http/ApiServerTest.java
#	frontend/README.md
2026-10-24 17:12:06 +02:00
root d07be384a1 Merge PR #85: feat: heat pump statistics in Historia
CI / Check (gateway, backend, frontend) (push) Successful in 1m24s
2026-10-24 17:09:59 +02:00
root 860269a353 Merge master into feat/heat-pump-stats
CI / Check (gateway, backend, frontend) (push) Successful in 1m26s
# Conflicts:
#	backend/src/main/java/home/backend/Backend.java
#	backend/src/main/java/home/backend/adapters/http/ApiServer.java
#	backend/src/test/java/home/backend/adapters/http/ApiServerTest.java
#	frontend/README.md
2026-10-24 17:07:30 +02:00
rootandClaude Opus 5.5 e4481eeeae chore(ci): rerun after a Maven download reset
CI / Check (gateway, backend, frontend) (push) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:05:01 +02:00
root e1e50eb7c2 Merge PR #84: feat: change history in Ustawienia
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
2026-10-24 17:03:31 +02:00
root c7d88e6c0b Merge PR #83: feat(frontend): installable PWA with an offline app shell 2026-10-24 17:03:31 +02:00
rootandClaude Opus 5.5 186c1984c0 feat: weather, PV estimate and IMGW warnings on Podgląd
CI / Check (gateway, backend, frontend) (push) Failing after 20s
Backend GET /api/weather: Open-Meteo forecast for three days, a PV
estimate calibrated on the last 14 measured days (Deye + Fronius daily
energy over forecast radiation), and IMGW warnings for Krosno (TERYT
1862, else 1807). Cached 30 and 10 minutes, stale answer on failure.
Frontend: Pogoda card, warning banners, and a hint on Bateria to keep
the battery full on a storm or strong-wind warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:00:40 +02:00
root 83d9e1ecfc Merge PR #82: chore(frontend): split the bundle, charts in their own chunk
CI / Check (gateway, backend, frontend) (push) Successful in 1m28s
2026-10-24 16:59:51 +02:00
rootandClaude Opus 5.5 cbf507fa18 feat: heat pump statistics per day in the Historia tab
CI / Check (gateway, backend, frontend) (push) Successful in 1m24s
GET /api/stats?range=7d|30d computes compressor, CWU, CO and heater hours,
compressor starts and temperatures per Europe/Warsaw day from the raw
heat_pump polls. Hours hold each poll for at most 600 s so gaps add nothing;
starts are 0 -> >0 transitions and are a lower bound at 300 s polling.
Historia gets a Wykresy | Statystyki switch (shared SubView helper, also
used by Energia) with totals tiles, a daily chart and a cycling hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:59:42 +02:00
rootandClaude Opus 5.5 a99f2b677a feat(frontend): show the change history in Ustawienia
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:59:26 +02:00
rootandClaude Opus 5.5 928915d059 feat(backend): serve the change history from the gateway's audit rows
GET /api/audit merges the heat pump and Deye audit measurements, newest
first, with Polish labels for the setpoints and Deye registers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:57:21 +02:00
rootandClaude Opus 5.5 c0c46973e8 chore(frontend): split the chart library and lazy-load screens
CI / Check (gateway, backend, frontend) (push) Successful in 1m29s
The single index chunk (672 kB, gzip 199 kB) triggered Vite's 500 kB
warning. Recharts goes into its own "charts" chunk; Historia, Ustawienia
and Stan load on demand behind Suspense ("Ładowanie…"). Energy stays in
the main chunk because the first screen imports EnergyToday from it.

Before: index 671.93 kB (gzip 199.08 kB).
After: index 264.23 kB (gzip 83.46 kB), charts 395.52 kB (gzip 113.20 kB),
Settings 9.82 kB, History 2.43 kB, Status 1.72 kB. No warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:55:48 +02:00
rootandClaude Opus 5.5 64e1e6ffec feat(frontend): installable PWA with an offline app shell
CI / Check (gateway, backend, frontend) (push) Successful in 1m26s
Manifest, icons (192, 512, maskable, apple-touch-icon) and iOS meta tags let the
phone add the app to the home screen. A service worker precaches index.html and
the built files, so the app opens offline and shows its own "offline" state; /api/
and any non-GET or cross-origin request always go to the network. Navigations are
network first so an update shows at once; the cache name carries a hash of the file
list and old caches are deleted on activate.

The worker is hand-written (src/sw.js, ~40 lines) with the file list injected by a
10-line Vite plugin, instead of vite-plugin-pwa: that pulls in Workbox and a large
dependency tree for what is a precache plus one fetch rule. The bottom nav already
pads for the iPhone home indicator (viewport-fit=cover, safe-area-inset-bottom).

nginx: manifest-src in the CSP and application/manifest+json for the manifest
(mime.types lacks it). sw.js and index.html were already no-cache, hashed assets
immutable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:55:47 +02:00
root b288c98106 Merge PR #81: feat: battery schedule and reserve from the app (ADR-013)
CI / Check (gateway, backend, frontend) (push) Successful in 1m27s
Release / Images (push) Successful in 3m2s
2026-10-24 15:55:04 +02:00
rootandClaude Opus 5.5 e5e13cedb5 docs: ADR-013 battery settings from the app, spec F8, register notes
CI / Check (gateway, backend, frontend) (push) Successful in 1m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:51:47 +02:00
rootandClaude Opus 5.5 a594512f33 feat(frontend): edit the battery reserve and schedule in the Bateria view
Reserve stepper with the API's bounds, schedule edit mode with a
confirmation summary and Polish results; hidden when not editable.
Stepper and Button move to ui.tsx for reuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:31:13 +02:00
rootandClaude Opus 5.5 a5ba9e8756 feat(backend): change the battery schedule and reserve through the gateway
POST /api/battery/schedule and /api/battery/reserve with the same
guards as the other writes; /api/battery shows the reserve and whether
changes are possible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:26:58 +02:00
rootandClaude Opus 5.5 619c3ee990 feat(gateway): change the Deye battery schedule and reserve (off by default)
Token-protected /battery/schedule and /battery/reserve: allowlisted Time
of Use changes checked as a whole against a fresh reading and the live
registers, FC16 per range, read-back, audit, rate limit per register.
The reserve is an app setting kept in reserve.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:23:33 +02:00
rootandClaude Opus 5.5 8111405326 feat(gateway): write multiple holding registers (FC16)
CI / Check (gateway, backend, frontend) (push) Successful in 1m21s
Needed for the Deye's program table; works over Solarman V5 unchanged,
as the RTU frame wraps any PDU. The echo of address and count is checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:03:29 +02:00
root 965dfae644 Merge PR #80: feat: Bateria and Falowniki views in the Energia tab
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
2026-10-24 14:57:52 +02:00
rootandClaude Opus 5.5 2da7246fe4 feat(frontend): Bateria and Falowniki views in the Energia tab
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
A segmented switch (Teraz, Bateria, Falowniki) kept in the URL hash so
Back returns to the previous view; tapping the battery in the flow
diagram opens Bateria. The device list moves to Falowniki.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:55:06 +02:00
rootandClaude Opus 5.5 a4dc88cbbc feat(backend): serve the Deye battery state and settings at /api/battery
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:51:44 +02:00
rootandClaude Opus 5.5 a01c507a6e feat(gateway): read the Deye battery settings into electricity.deye_settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:49:38 +02:00
root a8ef638fcd Merge PR #79: feat: red accent for running parts and a Salon sensor card
CI / Check (gateway, backend, frontend) (push) Successful in 1m22s
Release / Images (push) Successful in 2m53s
2026-10-24 14:10:27 +02:00
root 8396372943 Merge PR #78: feat(frontend): show the direction of energy flows with arrows
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
2026-10-24 14:07:59 +02:00
rootandClaude Opus 5.5 25f76ea084 feat(frontend): red accent for running parts and a Salon sensor card
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
A running pump, pump chip and headline are red instead of green; stopped
stays grey and the alarm is now solid so the two cannot be confused. The
BME280 reading shows in a Salon card, with brak odczytu when stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:07:37 +02:00
rootandClaude Opus 5.5 8146296061 feat(backend): serve the Raspberry Pi BME280 reading at /api/pi-sensor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:07:37 +02:00
rootandClaude Opus 5.5 c21094b4f4 feat(frontend): show the direction of energy flows with arrows
CI / Check (gateway, backend, frontend) (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:05:25 +02:00
root 3b6d073b1f Merge PR #77: feat(gateway): poll the heat pump again soon after a write
CI / Check (gateway, backend, frontend) (push) Successful in 1m20s
2026-10-24 14:01:04 +02:00
root 63c552dfd7 Merge PR #76: feat: choose the away temperatures in Wyjazd
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
2026-10-24 13:59:00 +02:00
rootandClaude Opus 5.5 de19424a7b feat(gateway): poll the heat pump again 3 s after a write or restore
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
The derived targets and setpoint fields stayed old until the next 300 s poll.
The extra poll runs on the device's own poller thread, so it never overlaps
another read, and requests within the delay coalesce into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:57:59 +02:00
rootandClaude Opus 5.5 03b4ee1a48 feat(frontend): choose the away temperatures in Wyjazd
CI / Check (gateway, backend, frontend) (push) Successful in 1m19s
One stepper per preset change under Powrót, lowering only; a setpoint
already at its minimum disables the row and the confirmation. The steppers
of Nastawy and Wyjazd share one component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:55:52 +02:00
rootandClaude Opus 5.5 355dd76555 feat(backend): let a preset take the user's own values
POST /api/presets/{id} accepts optional values per setpoint of the preset,
checked against the gateway's limits before anything is sent, so Wyjazd
can use temperatures other than the defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:54:45 +02:00
252 changed files with 16024 additions and 748 deletions

No files matched your search

+2 -1
View File
@@ -24,7 +24,8 @@ Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/
then `git push origin master` and delete the branch locally and on origin. Confirm CI is then `git push origin master` and delete the branch locally and on origin. Confirm CI is
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it. green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
6. Release and update only with the user's explicit approval for this one: 6. Release and update only with the user's explicit approval for this one:
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) tags master; `scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) waits for green
CI on master's head (no local check run), then tags master;
wait until the Release run under `…/actions` is green (it publishes the images). Then on the wait until the Release run under `…/actions` is green (it publishes the images). Then on the
Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`, Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`,
watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and
+3
View File
@@ -1,3 +1,6 @@
# One job: check.sh builds the three parts in parallel itself. The runner runs one job at a time
# and its actions cache server is unreachable from job containers (ETIMEDOUT), so separate jobs
# or setup-java/setup-node caches would only add waiting.
name: CI name: CI
on: on:
+20 -23
View File
@@ -13,8 +13,20 @@ env:
jobs: jobs:
images: images:
name: Images name: Image (${{ matrix.part }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into
# the final stage, so the arm64 image needs no emulation.
- part: gateway
platforms: linux/arm64,linux/amd64
- part: backend
platforms: linux/amd64
- part: frontend
platforms: linux/amd64
steps: steps:
- name: Checkout - name: Checkout
@@ -31,29 +43,14 @@ jobs:
username: ${{ gitea.repository_owner }} username: ${{ gitea.repository_owner }}
# The owner's token with package write access (the secret homelab-upgrade uses too). # The owner's token with package write access (the secret homelab-upgrade uses too).
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into the # The registry cache keeps the Maven and npm layers between releases.
# final stage, so the arm64 image needs no emulation. - name: Build and push
- name: Gateway
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: gateway context: ${{ matrix.part }}
build-args: VERSION=${{ gitea.ref_name }} build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/arm64,linux/amd64 platforms: ${{ matrix.platforms }}
push: true push: true
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest tags: ${{ env.IMAGES }}-${{ matrix.part }}:${{ gitea.ref_name }},${{ env.IMAGES }}-${{ matrix.part }}:latest
- name: Backend cache-from: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache
uses: docker/build-push-action@v6 cache-to: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache,mode=max
with:
context: backend
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/amd64
push: true
tags: ${{ env.IMAGES }}-backend:${{ gitea.ref_name }},${{ env.IMAGES }}-backend:latest
- name: Frontend
uses: docker/build-push-action@v6
with:
context: frontend
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/amd64
push: true
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
+3 -3
View File
@@ -15,8 +15,8 @@ USER 10001:10001
WORKDIR /app WORKDIR /app
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
EXPOSE 8080 EXPOSE 8080
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body), # /api/live needs no session and does not look at the gateway, so it only tells whether the backend
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine. # itself serves requests. busybox wget ships with alpine.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/health" || exit 1 CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/live" || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-jar", "app.jar"]
+118 -6
View File
@@ -3,11 +3,55 @@
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010). changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract. The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
`GET /api/energy` (example `src/test/resources/api/energy.json`) is the house electricity now. `housePowerW` is the whole house by balance, because the Deye `load` registers cover only its backup output (`backupOutputW`): Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, the grid being the house meter's `total_power_active` when live, else the Deye `grid_power_w`; floored at 0, null when any of these readings is missing or stale. `today.houseKwh` is Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged (Deye day counters), null when any is missing.
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there. `GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
`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 Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
without the two URLs. without the two URLs.
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`. Setpoint changes go only to the gateway; the signed-in user is sent as `X-Requested-By`.
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
@@ -17,29 +61,97 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us
| `GATEWAY_TIMEOUT` | `5` | seconds | | `GATEWAY_TIMEOUT` | `5` | seconds |
| `INFLUX_TIMEOUT` | `10` | seconds | | `INFLUX_TIMEOUT` | `10` | seconds |
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 | | `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
| `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 | | `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
```bash ```bash
mvn -f backend/pom.xml clean package mvn -f backend/pom.xml clean package
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \ GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example AUTH_DISABLED=true \
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/ java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
``` ```
## Sign-in (ADR-014)
Without `USERS_FILE` or `OIDC_ISSUER` the backend refuses to start unless `AUTH_DISABLED=true`. The two ways in are
independent: local accounts keep working when Keycloak is down, and the backend never waits for Keycloak at start
(discovery and keys are fetched at the first sign-in and kept an hour). Every endpoint but those below answers
`401 {"error":"unauthenticated"}` without a valid session; the writes keep their Origin, header and JSON rules.
| Endpoint | Answer |
|---|---|
| `GET /api/auth/config` | `{"local", "oidc", "setup"}` booleans (`auth-config.json`); `setup` is true while `USERS_FILE` has no account |
| `POST /api/auth/setup` `{"username","password"}` | first account, once: 200 `{"user","method":"local"}` and the cookie; 409 `{"error":"setup_done"}` once any account exists; 400 `weak_password` (under 10 characters) or `invalid_username`; 404 without `USERS_FILE` |
| `GET /api/auth/users` | session needed: `[{"username"}]` |
| `POST /api/auth/users` `{"username","password"}` | session needed: adds a local account; 200 `{"username"}`, 409 `exists`, 400 as setup |
| `POST /api/auth/password` `{"current","new"}` | session needed, local sessions only (403 `local_only` otherwise): 204; 401 `invalid` (counts toward the throttle), 429, 400 `weak_password` |
| `POST /api/auth/users/{name}/delete` | session needed: 204 (removing yourself also clears your cookie); 404 `unknown_user`; 409 `last_user` |
| `POST /api/auth/login` `{"username","password"}` | 200 `{"user","method":"local"}` and the cookie; 401 `{"error":"invalid"}`; 429 `{"error":"throttled","retryAfterSeconds"}` with `Retry-After`; 404 without `USERS_FILE` |
| `POST /api/auth/logout` | 204, cookie cleared |
| `GET /api/auth/me` | 200 `{"user","method"}` (`auth-me.json`; `method` is `local`, `oidc`, or `none` with `AUTH_DISABLED`) or 401 |
| `GET /api/auth/oidc/start` | 302 to Keycloak; 404 without OIDC |
| `GET /api/auth/oidc/callback` | 302 to `/`, or to `/#login?error=oidc_unavailable` (Keycloak down), `oidc_denied` (refused or role missing), `oidc_failed` (bad state, token or code) |
| `GET /api/live` | 200 `ok`, for the container healthcheck and external monitors: `/api/health` needs a session now |
The session is a cookie `hp_session` (HttpOnly, Secure, SameSite=Lax, Path=/) signed with HMAC-SHA256 and holding user, method,
issue time and expiry; there is no server state, so a stolen cookie lives until it expires or the secret changes; a `local` session also ends when its account
is removed (it is checked on every request).
The OIDC flow is authorization code with PKCE S256, state and nonce, in a 10 minute signed cookie `hp_oidc`; the ID token is
verified here (RS256 against the JWKS key by `kid`, `iss`, `aud` and `azp`, `exp`/`iat` with 60 s skew, `nonce`).
The user name is `preferred_username`, else `email`, else `sub`.
**Local accounts**: `USERS_FILE` is JSON `[{"username": "tata", "passwordHash": "pbkdf2-sha256$600000$<salt>$<hash>"}]`, a username of
1 to 64 of `A-Za-z0-9_.@+-`, a bad file stops the start. The app edits the file: it rewrites it atomically (temp file in the same directory, fsync, rename, mode 600), so mount a **directory** (`./auth:/config/auth`, `USERS_FILE=/config/auth/users.json`) that uid 10001 owns: `chown 10001:10001 auth`. A missing file or one with no accounts is setup mode: the first visitor to the app creates the first account in the app (accepted risk, ADR-014: Traefik admits only the two home networks). Add, remove accounts and change a password in the app; each change is logged (who, what, never a password). A hash can still be made by hand, without echoing the password (asked twice on a terminal;
a pipe reads one line):
```bash
docker run --rm -it git.mariuszpacek.pl/mariusz.pacek/home-pump-backend:latest hash-password
java -jar backend/target/home-pump-backend-1.0.0.jar hash-password
```
Five login attempts in 15 minutes block that username and, separately, that client address (429, `Retry-After`; an attempt is
counted before the password is checked, so parallel guesses cannot outrun it, and a success clears the username); a blocked
username is refused even with the right password. At most 4 passwords are hashed at once, more get 429 at once. An unknown username costs one password hash like a wrong password.
The address is the entry `TRUSTED_PROXY_HOPS` from the right of `X-Forwarded-For` (Traefik appends the address it saw;
what a client put in front is ignored); without it the socket's address.
**Keycloak client** in the realm of `OIDC_ISSUER`: Client authentication on (confidential), only Standard flow, PKCE method
`S256`, valid redirect URI `https://pompa.mariuszpacek.pl/api/auth/oidc/callback`, web origin `https://pompa.mariuszpacek.pl`.
Its secret is `OIDC_CLIENT_SECRET`. Without `OIDC_REQUIRED_ROLE`, every user of the realm can sign in; the backend logs a warning at start. For `OIDC_REQUIRED_ROLE`, the client scope `roles` must put the roles into the ID token
(its realm and client role mappers: "Add to ID token" on).
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd") `POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`): needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
400 when it is missing, invalid, or sent to a preset that has none. The gateway holds those changes until then without 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). renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
`POST /api/refresh` (same Origin, header and JSON rules; send `{}`) is the "Odśwież" button: it asks the gateway for one
extra poll of every device and passes its answer through, example `src/test/resources/api/refresh.json`
(`requested`, and `skipped` with `retryInSeconds` for devices polled within the last minute). Read-only; 503 when
`GATEWAY_TOKEN` is not set or the gateway is unreachable.
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the `POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
pending change of that setpoint now and answers with the gateway's verdict, example pending change of that setpoint now and answers with the gateway's verdict, example
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed `src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
pending), 503 gateway unreachable. pending), 503 gateway unreachable.
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It Publish no host port in production: only the frontend, behind Traefik, may reach the backend, so Traefik's
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access access rules (ADR-009) stay in front of the sign-in.
rules (ADR-009).
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and `/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it). `gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
+7
View File
@@ -9,6 +9,13 @@ services:
env_file: .env env_file: .env
environment: environment:
TZ: Europe/Warsaw TZ: Europe/Warsaw
# Sign-in (README): the accounts directory, writable by uid 10001 (chown 10001:10001 auth); the app rewrites users.json in it. In .env, for example:
# USERS_FILE=/config/auth/users.json SESSION_SECRET=<openssl rand -base64 48>
# OIDC_ISSUER=https://login.mariuszpacek.pl/realms/home OIDC_CLIENT_ID=home-pump
# OIDC_CLIENT_SECRET=<from Keycloak> OIDC_REQUIRED_ROLE=family
# OIDC_REDIRECT_URI=https://pompa.mariuszpacek.pl/api/auth/oidc/callback
volumes:
- ./auth:/config/auth
networks: [home-pump] networks: [home-pump]
networks: networks:
@@ -1,44 +1,77 @@
package home.backend; package home.backend;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.config.PresetFile; import home.backend.adapters.config.PresetFile;
import home.backend.adapters.gateway.GatewayHttpClient; import home.backend.adapters.gateway.GatewayHttpClient;
import home.backend.adapters.gateway.GatewayHttpCommands; import home.backend.adapters.gateway.GatewayHttpCommands;
import home.backend.adapters.http.ApiServer; import home.backend.adapters.http.ApiServer;
import home.backend.adapters.influx.InfluxAuditRepository;
import home.backend.adapters.influx.InfluxHistoryRepository; import home.backend.adapters.influx.InfluxHistoryRepository;
import home.backend.adapters.influx.InfluxPvEnergy;
import home.backend.adapters.influx.InfluxSettings; import home.backend.adapters.influx.InfluxSettings;
import home.backend.adapters.weather.ImgwClient;
import home.backend.adapters.weather.OpenMeteoClient;
import home.backend.adapters.weather.WeatherSettings;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService; import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService; import home.backend.application.EnergyService;
import home.backend.application.HealthService; import home.backend.application.HealthService;
import home.backend.application.HeatPumpService; import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService; import home.backend.application.HistoryService;
import home.backend.application.ProgramService;
import home.backend.application.StatsService;
import home.backend.application.LeaseRenewer; import home.backend.application.LeaseRenewer;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService; import home.backend.application.SetpointService;
import home.backend.application.WeatherService;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.time.Clock; import java.time.Clock;
import java.time.Duration;
import java.util.Objects; import java.util.Objects;
/** Wires the adapters and services together; the only place that knows all of them. */ /** Wires the adapters and services together; the only place that knows all of them. */
final class Backend { final class Backend {
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
/** Warnings are optional, so a slow IMGW must not hold the forecast. */
private static final Duration WARNINGS_TIMEOUT = Duration.ofSeconds(3);
private Backend() { private Backend() {
} }
/** /**
* Binds the port but does not start serving. * Binds the port but does not start serving.
* *
* @throws IllegalArgumentException when PRESETS_FILE is unusable * @throws IllegalArgumentException when PRESETS_FILE or USERS_FILE is unusable
*/ */
static ApiServer assemble(Settings settings, Clock clock) throws IOException { static ApiServer assemble(Settings settings, Clock clock) throws IOException {
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout()); var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout())); var influxSettings = new InfluxSettings(settings.influxUrl(), settings.influxTimeout());
var setpoints = new SetpointService(commands(settings), PresetFile.load(settings.presetsFile())); var influx = new InfluxHistoryRepository(influxSettings);
var commands = commands(settings);
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
return new ApiServer(settings.port(), return new ApiServer(settings.port(),
new HeatPumpService(gateway, clock), new HeatPumpService(gateway, clock),
new EnergyService(gateway, clock), new EnergyService(gateway, clock),
new EnergyDetailsService(gateway, clock), new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")), new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
new HistoryService(influx, clock), setpoints); 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). */ /** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
@@ -2,6 +2,8 @@ package home.backend;
import home.backend.adapters.http.ApiServer; import home.backend.adapters.http.ApiServer;
import home.backend.adapters.auth.PasswordHashCli;
import java.io.IOException; import java.io.IOException;
import java.time.Clock; import java.time.Clock;
@@ -11,6 +13,9 @@ public final class Main {
} }
public static void main(String[] args) throws IOException { public static void main(String[] args) throws IOException {
if (args.length == 1 && args[0].equals("hash-password")) {
System.exit(PasswordHashCli.run(System.console(), System.in, System.out, System.err));
}
Settings settings; Settings settings;
ApiServer server; ApiServer server;
try { try {
@@ -21,6 +26,7 @@ public final class Main {
System.exit(2); System.exit(2);
return; return;
} }
settings.auth().warnings().forEach(w -> System.err.println("home-pump-backend: WARNING: " + w));
Runtime.getRuntime().addShutdownHook(new Thread(server::stop)); Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
if (settings.gatewayToken() == null) { if (settings.gatewayToken() == null) {
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled"); System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
@@ -1,6 +1,10 @@
package home.backend; package home.backend;
import home.backend.adapters.auth.AuthSettings;
import home.backend.adapters.auth.OidcSettings;
import java.net.URI; import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.util.Map; import java.util.Map;
@@ -8,7 +12,7 @@ import java.util.Optional;
/** Twelve-factor settings: everything comes from environment variables. */ /** Twelve-factor settings: everything comes from environment variables. */
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout, public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
String gatewayToken, Path presetsFile) { String gatewayToken, Path presetsFile, AuthSettings auth) {
public static Settings fromEnvironment(Map<String, String> env) { public static Settings fromEnvironment(Map<String, String> env) {
return new Settings( return new Settings(
@@ -18,7 +22,44 @@ public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gateway
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)), Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)), Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
optional(env, "GATEWAY_TOKEN"), optional(env, "GATEWAY_TOKEN"),
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null)); Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null),
auth(env));
}
private static final int MIN_SECRET_BYTES = 32;
/** Refuses to start open by accident: sign-in is configured, or AUTH_DISABLED=true says it is meant. */
private static AuthSettings auth(Map<String, String> env) {
Path usersFile = Optional.ofNullable(optional(env, "USERS_FILE")).map(Path::of).orElse(null);
OidcSettings oidc = optional(env, "OIDC_ISSUER") == null ? null : oidc(env);
String secret = optional(env, "SESSION_SECRET");
boolean disabled = "true".equals(optional(env, "AUTH_DISABLED"));
int days = (int) number(env, "SESSION_DAYS", 30, 1, 365);
int hops = (int) number(env, "TRUSTED_PROXY_HOPS", 1, 1, 10);
boolean configured = usersFile != null || oidc != null;
if (disabled && configured) {
throw new IllegalArgumentException("AUTH_DISABLED=true contradicts USERS_FILE and OIDC_ISSUER: unset one");
}
if (!disabled && !configured) {
throw new IllegalArgumentException("no sign-in configured: set USERS_FILE and/or OIDC_ISSUER, "
+ "or AUTH_DISABLED=true for development only");
}
if (configured && (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < MIN_SECRET_BYTES)) {
throw new IllegalArgumentException("SESSION_SECRET is required and must be at least " + MIN_SECRET_BYTES
+ " bytes, e.g. from: openssl rand -base64 48");
}
return new AuthSettings(usersFile, oidc, secret, days, disabled, hops);
}
private static OidcSettings oidc(Map<String, String> env) {
for (String name : new String[] {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI"}) {
if (optional(env, name) == null) {
throw new IllegalArgumentException(name + " is required when OIDC_ISSUER is set");
}
}
return new OidcSettings(URI.create(optional(env, "OIDC_ISSUER")), optional(env, "OIDC_CLIENT_ID"),
optional(env, "OIDC_CLIENT_SECRET"), URI.create(optional(env, "OIDC_REDIRECT_URI")),
optional(env, "OIDC_REQUIRED_ROLE"));
} }
/** Null when unset or blank. */ /** Null when unset or blank. */
@@ -0,0 +1,20 @@
package home.backend.adapters.auth;
import java.nio.file.Path;
import java.util.List;
/**
* How users sign in. At least one of usersFile and oidc, or disabled; the secret signs the cookies.
* proxyHops is how many proxies append to X-Forwarded-For before the backend (Traefik: 1).
*/
public record AuthSettings(Path usersFile, OidcSettings oidc, String sessionSecret, int sessionDays,
boolean disabled, int proxyHops) {
/** Things worth saying at startup. */
public List<String> warnings() {
if (oidc != null && oidc.requiredRole() == null) {
return List.of("OIDC_REQUIRED_ROLE is not set: every user of the Keycloak realm can sign in");
}
return List.of();
}
}
@@ -0,0 +1,166 @@
package home.backend.adapters.auth;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
import java.util.concurrent.Semaphore;
/** Who may use the API: local accounts and OIDC, independent of each other, both ending in a session cookie. */
public final class Authentication {
public static final String LOCAL = "local";
public enum Status { OK, INVALID, THROTTLED }
public record Login(Status status, long retryAfterSeconds) {
}
private static final int MAX_FAILURES = 5;
private static final Duration THROTTLE_WINDOW = Duration.ofMinutes(15);
/** Password hashing is slow on purpose; a flood must not queue up behind it. */
private static final int PARALLEL_HASHES = 4;
private static final int MIN_PASSWORD = 10;
private static final long BUSY_RETRY_SECONDS = 1;
private final Sessions sessions;
private final UserStore users;
private final LoginThrottle throttle;
private final OidcFlow oidc;
private final int proxyHops;
private final Semaphore hashing = new Semaphore(PARALLEL_HASHES);
private Authentication(Sessions sessions, UserStore users, LoginThrottle throttle, OidcFlow oidc, int proxyHops) {
this.sessions = sessions;
this.users = users;
this.throttle = throttle;
this.oidc = oidc;
this.proxyHops = proxyHops;
}
/** @throws IllegalArgumentException when USERS_FILE is unusable */
public static Authentication create(AuthSettings settings, Clock clock) {
if (settings.disabled()) {
return new Authentication(null, null, null, null, settings.proxyHops());
}
var tokens = new SignedTokens(settings.sessionSecret().getBytes(StandardCharsets.UTF_8), clock);
return new Authentication(new Sessions(tokens, clock, Duration.ofDays(settings.sessionDays())),
settings.usersFile() == null ? null : UserStore.load(settings.usersFile()),
new LoginThrottle(clock, MAX_FAILURES, THROTTLE_WINDOW),
settings.oidc() == null ? null : new OidcFlow(settings.oidc(), tokens, clock),
settings.proxyHops());
}
/** For development and tests: every request is the user "phone". */
public static Authentication disabled() {
return new Authentication(null, null, null, null, 1);
}
public boolean localEnabled() {
return users != null;
}
public boolean oidcEnabled() {
return oidc != null;
}
public OidcFlow oidc() {
return oidc;
}
public int proxyHops() {
return proxyHops;
}
public Optional<Session> session(String cookieHeader) {
if (sessions == null) {
return Optional.of(Session.OPEN);
}
// A removed account loses its sessions at once; OIDC sessions end with their expiry.
return sessions.read(cookieHeader).filter(s -> !LOCAL.equals(s.method()) || users != null && users.exists(s.user()));
}
/** Checks the password unless the user or the address is blocked; a blocked one learns nothing about it. */
public Login login(String username, char[] password, String clientIp) {
String userKey = "user:" + username.toLowerCase(Locale.ROOT);
String ipKey = "ip:" + clientIp;
long wait = throttle.tryAcquire(userKey, ipKey);
if (wait > 0) {
return new Login(Status.THROTTLED, wait);
}
if (!hashing.tryAcquire()) {
throttle.release(userKey, ipKey);
return new Login(Status.THROTTLED, BUSY_RETRY_SECONDS);
}
try {
if (users.authenticate(username, password)) {
throttle.cleared(userKey);
throttle.release(ipKey);
return new Login(Status.OK, 0);
}
return new Login(Status.INVALID, 0);
} finally {
hashing.release();
}
}
public boolean setupNeeded() {
return users != null && users.isEmpty();
}
public static boolean strongEnough(String password) {
return password.length() >= MIN_PASSWORD;
}
/** Creates the first account, once: OK for the winner, INVALID (setup is done) for everyone after. */
public Login setup(String username, char[] password, String clientIp) {
String ipKey = "ip:" + clientIp;
long wait = throttle.tryAcquire(ipKey);
if (wait > 0) {
return new Login(Status.THROTTLED, wait);
}
if (!users.addFirst(username, PasswordHash.create(password))) {
return new Login(Status.INVALID, 0);
}
System.err.println("home-pump-backend: setup created the first account " + username);
throttle.release(ipKey);
return new Login(Status.OK, 0);
}
public java.util.List<String> usernames() {
return users.usernames();
}
/** @return false when the name is taken */
public boolean addUser(String by, String username, char[] password) {
boolean added = users.add(username, PasswordHash.create(password));
System.err.println("home-pump-backend: " + by + " added account " + username + (added ? "" : " (refused, exists)"));
return added;
}
/** Checks the current password like a login does (throttled), then sets the new one. */
public Login changePassword(String username, char[] current, char[] next, String clientIp) {
Login login = login(username, current, clientIp);
if (login.status() == Status.OK) {
users.changePassword(username, PasswordHash.create(next));
System.err.println("home-pump-backend: " + username + " changed their password");
}
return login;
}
public UserStore.Removal removeUser(String by, String username) {
var removal = users.remove(username);
System.err.println("home-pump-backend: " + by + " removed account " + username + ": " + removal);
return removal;
}
/** The Set-Cookie header value that signs the user in. */
public String sessionCookie(String user, String method) {
return sessions.setCookie(user, method);
}
public String clearedSessionCookie() {
return sessions == null ? "" : sessions.clearCookie();
}
}
@@ -0,0 +1,18 @@
package home.backend.adapters.auth;
import java.util.Base64;
/** Unpadded base64url, as tokens and PKCE use it. */
final class Base64Url {
private Base64Url() {
}
static String encode(byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static byte[] decode(String text) {
return Base64.getUrlDecoder().decode(text);
}
}
@@ -0,0 +1,36 @@
package home.backend.adapters.auth;
import java.time.Duration;
import java.util.Optional;
/** Reads the Cookie header and builds Set-Cookie headers with the flags every cookie here carries. */
final class Cookies {
private Cookies() {
}
static Optional<String> read(String header, String name) {
if (header == null) {
return Optional.empty();
}
for (String pair : header.split(";")) {
String[] kv = pair.trim().split("=", 2);
if (kv.length == 2 && kv[0].equals(name)) {
return Optional.of(kv[1]);
}
}
return Optional.empty();
}
static String set(String name, String value, Duration maxAge, String path) {
return name + "=" + value + "; Max-Age=" + maxAge.toSeconds() + attributes(path);
}
static String clear(String name, String path) {
return name + "=; Max-Age=0" + attributes(path);
}
private static String attributes(String path) {
return "; Path=" + path + "; HttpOnly; Secure; SameSite=Lax";
}
}
@@ -0,0 +1,143 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.Signature;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
/** Checks an ID token with the JDK alone: RS256 signature, issuer, audience, times, nonce and the required role. */
final class IdTokenVerifier {
/** The signing keys of the identity provider. */
interface Keys {
PublicKey key(String kid) throws OidcException;
}
private static final ObjectMapper JSON = new ObjectMapper();
private static final Duration SKEW = Duration.ofSeconds(60);
private final OidcSettings settings;
private final Clock clock;
IdTokenVerifier(OidcSettings settings, Clock clock) {
this.settings = settings;
this.clock = clock;
}
/** @return the user name of the token */
String verify(String idToken, String nonce, Keys keys) throws OidcException {
String[] parts = idToken.split("\\.", -1);
if (parts.length != 3) {
throw new OidcException(FAILED, "the ID token is not a JWT");
}
JsonNode header = json(parts[0]);
if (!"RS256".equals(header.path("alg").asText())) {
throw new OidcException(FAILED, "the ID token is not signed with RS256");
}
verifySignature(parts[0] + "." + parts[1], parts[2], keys.key(header.path("kid").asText()));
JsonNode claims = json(parts[1]);
checkClaims(claims, nonce);
checkRole(claims);
return userName(claims);
}
private void checkClaims(JsonNode claims, String nonce) throws OidcException {
long now = clock.instant().getEpochSecond();
if (!settings.issuerBase().equals(claims.path("iss").asText("").replaceAll("/+$", ""))) {
throw new OidcException(FAILED, "wrong issuer");
}
if (!audiences(claims).contains(settings.clientId())) {
throw new OidcException(FAILED, "the token is for another client");
}
if (audiences(claims).size() > 1 && !settings.clientId().equals(claims.path("azp").asText())) {
throw new OidcException(FAILED, "wrong authorized party");
}
if (!claims.path("exp").canConvertToLong() || claims.get("exp").asLong() + SKEW.toSeconds() <= now) {
throw new OidcException(FAILED, "the token has expired");
}
if (!claims.path("iat").canConvertToLong() || claims.get("iat").asLong() - SKEW.toSeconds() > now) {
throw new OidcException(FAILED, "the token is issued in the future");
}
boolean sameNonce = MessageDigest.isEqual(nonce.getBytes(StandardCharsets.UTF_8),
claims.path("nonce").asText("").getBytes(StandardCharsets.UTF_8));
if (!sameNonce) {
throw new OidcException(FAILED, "wrong nonce");
}
}
/** A realm role or a role of this client, as Keycloak puts them in {@code realm_access} and {@code resource_access}. */
private void checkRole(JsonNode claims) throws OidcException {
String role = settings.requiredRole();
if (role == null) {
return;
}
boolean has = contains(claims.path("realm_access").path("roles"), role)
|| contains(claims.path("resource_access").path(settings.clientId()).path("roles"), role);
if (!has) {
throw new OidcException(DENIED, "the user lacks the role " + role);
}
}
private String userName(JsonNode claims) throws OidcException {
for (String claim : new String[] {"preferred_username", "email", "sub"}) {
String name = claims.path(claim).asText("");
if (Session.isValidUser(name)) {
return name;
}
}
throw new OidcException(FAILED, "the token has no usable user name");
}
private static List<String> audiences(JsonNode claims) {
JsonNode aud = claims.path("aud");
var all = new ArrayList<String>();
if (aud.isArray()) {
aud.forEach(a -> all.add(a.asText()));
} else if (aud.isTextual()) {
all.add(aud.asText());
}
return all;
}
private static boolean contains(JsonNode array, String value) {
for (JsonNode item : array) {
if (value.equals(item.asText())) {
return true;
}
}
return false;
}
private static void verifySignature(String signedPart, String signature, PublicKey key) throws OidcException {
try {
Signature rsa = Signature.getInstance("SHA256withRSA");
rsa.initVerify(key);
rsa.update(signedPart.getBytes(StandardCharsets.US_ASCII));
if (!rsa.verify(Base64Url.decode(signature))) {
throw new OidcException(FAILED, "the ID token signature is wrong");
}
} catch (GeneralSecurityException | IllegalArgumentException e) {
throw new OidcException(FAILED, "the ID token signature cannot be checked", e);
}
}
private static JsonNode json(String part) throws OidcException {
try {
return JSON.readTree(Base64Url.decode(part));
} catch (IOException | IllegalArgumentException e) {
throw new OidcException(FAILED, "the ID token is malformed", e);
}
}
}
@@ -0,0 +1,70 @@
package home.backend.adapters.auth;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Map;
/** Blocks a key (a username or a client address) after too many failures within a window. */
public final class LoginThrottle {
private final Clock clock;
private final int maxFailures;
private final Duration window;
/** The newest failures of each key, at most {@code maxFailures}, oldest first. */
private final Map<String, ArrayDeque<Instant>> failures = new HashMap<>();
public LoginThrottle(Clock clock, int maxFailures, Duration window) {
this.clock = clock;
this.maxFailures = maxFailures;
this.window = window;
}
/**
* Counts an attempt against every key before the password is checked, so parallel guesses cannot
* outrun the limit; {@link #release} takes it back.
*
* @return 0 when the attempt may go on, else the seconds until the most blocked key may try again
*/
public synchronized long tryAcquire(String... keys) {
Instant now = clock.instant();
long wait = 0;
for (String key : keys) {
var times = failures.get(key);
if (times != null && times.size() >= maxFailures && times.peekFirst().plus(window).isAfter(now)) {
wait = Math.max(wait, Duration.between(now, times.peekFirst().plus(window)).toSeconds() + 1);
}
}
if (wait > 0) {
return wait;
}
failures.values().removeIf(times -> !times.peekLast().plus(window).isAfter(now));
for (String key : keys) {
var times = failures.computeIfAbsent(key, k -> new ArrayDeque<>());
times.addLast(now);
if (times.size() > maxFailures) {
times.removeFirst();
}
}
return 0;
}
/** Takes back the newest attempt of each key. */
public synchronized void release(String... keys) {
for (String key : keys) {
var times = failures.get(key);
if (times != null) {
times.pollLast();
if (times.isEmpty()) {
failures.remove(key);
}
}
}
}
public synchronized void cleared(String key) {
failures.remove(key);
}
}
@@ -0,0 +1,40 @@
package home.backend.adapters.auth;
/** Why an OIDC sign-in did not happen; the code is what the login screen is told. */
public final class OidcException extends Exception {
public enum Reason {
/** Keycloak did not answer. */
UNAVAILABLE("oidc_unavailable"),
/** The user said no, or lacks the required role. */
DENIED("oidc_denied"),
/** Anything else: bad state, a token that does not verify, a refused code. */
FAILED("oidc_failed");
private final String code;
Reason(String code) {
this.code = code;
}
public String code() {
return code;
}
}
private final Reason reason;
public OidcException(Reason reason, String message) {
super(message);
this.reason = reason;
}
public OidcException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
public Reason reason() {
return reason;
}
}
@@ -0,0 +1,113 @@
package home.backend.adapters.auth;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
/**
* Authorization code flow with PKCE (S256), state and nonce. What must survive the trip to the identity
* provider travels in a short-lived signed cookie, so the backend keeps no state.
*/
public final class OidcFlow {
/** Where to send the browser, and the cookie to set on that response. */
public record Start(URI location, String setCookie) {
}
private static final String COOKIE = "hp_oidc";
private static final String PURPOSE = "oidc-login";
private static final String PATH = "/api/auth/oidc";
private static final Duration LOGIN_TIME = Duration.ofMinutes(10);
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private static final SecureRandom RANDOM = new SecureRandom();
private final OidcSettings settings;
private final OidcProvider provider;
private final IdTokenVerifier verifier;
private final SignedTokens tokens;
OidcFlow(OidcSettings settings, SignedTokens tokens, Clock clock) {
this.settings = settings;
this.provider = new OidcProvider(settings, TIMEOUT, clock);
this.verifier = new IdTokenVerifier(settings, clock);
this.tokens = tokens;
}
/** @throws OidcException UNAVAILABLE when the identity provider does not answer */
public Start start() throws OidcException {
String state = random();
String nonce = random();
String verifierText = random();
Map<String, String> query = new LinkedHashMap<>();
query.put("response_type", "code");
query.put("client_id", settings.clientId());
query.put("redirect_uri", settings.redirectUri().toString());
query.put("scope", "openid profile");
query.put("state", state);
query.put("nonce", nonce);
query.put("code_challenge", challenge(verifierText));
query.put("code_challenge_method", "S256");
URI endpoint = provider.endpoints().authorization();
String separator = endpoint.getRawQuery() == null ? "?" : "&";
String cookie = Cookies.set(COOKIE, tokens.issue(PURPOSE, LOGIN_TIME,
Map.of("state", state, "nonce", nonce, "verifier", verifierText)), LOGIN_TIME, PATH);
return new Start(URI.create(endpoint + separator + query.entrySet().stream()
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"))), cookie);
}
/** @return the signed-in user's name */
public String callback(Map<String, String> query, String cookieHeader) throws OidcException {
Map<String, String> login = Cookies.read(cookieHeader, COOKIE).flatMap(t -> tokens.read(PURPOSE, t))
.orElseThrow(() -> new OidcException(FAILED, "no login in progress, or it took too long"));
boolean sameState = MessageDigest.isEqual(login.get("state").getBytes(StandardCharsets.UTF_8),
query.getOrDefault("state", "").getBytes(StandardCharsets.UTF_8));
if (!sameState) {
throw new OidcException(FAILED, "wrong state");
}
if ("access_denied".equals(query.get("error"))) {
throw new OidcException(DENIED, "the user denied access");
}
String code = query.get("code");
if (code == null || code.isEmpty()) {
throw new OidcException(FAILED, "the identity provider answered with " + printable(query.get("error")));
}
return verifier.verify(provider.exchange(code, login.get("verifier")), login.get("nonce"), provider);
}
public String clearCookie() {
return Cookies.clear(COOKIE, PATH);
}
/** The provider's text goes into the log: letters, digits and a few separators only, and short. */
static String printable(String text) {
String safe = text == null ? "nothing" : text.replaceAll("[^A-Za-z0-9_. -]", "?");
return safe.length() > 40 ? safe.substring(0, 40) + "..." : safe;
}
private static String random() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64Url.encode(bytes);
}
private static String challenge(String verifierText) {
try {
return Base64Url.encode(MessageDigest.getInstance("SHA-256")
.digest(verifierText.getBytes(StandardCharsets.US_ASCII)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is part of every JDK", e);
}
}
}
@@ -0,0 +1,179 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
import static home.backend.adapters.auth.OidcException.Reason.UNAVAILABLE;
/**
* The identity provider over HTTP: discovery, signing keys and the code exchange. Everything is fetched on first
* use and kept, so an identity provider that is down never delays the start and never affects local login.
*/
final class OidcProvider implements IdTokenVerifier.Keys {
record Endpoints(URI authorization, URI token, URI jwks) {
}
private static final ObjectMapper JSON = new ObjectMapper();
private static final Duration CACHE = Duration.ofHours(1);
/** An unknown key id may mean a rotation; look again, but not on every forged token. */
private static final Duration REFETCH_AFTER = Duration.ofMinutes(1);
private final OidcSettings settings;
private final HttpClient http;
private final Duration timeout;
private final Clock clock;
private Endpoints endpoints;
private Instant endpointsAt = Instant.MIN;
private Map<String, PublicKey> keys = Map.of();
private Instant keysAt = Instant.MIN;
OidcProvider(OidcSettings settings, Duration timeout, Clock clock) {
this.settings = settings;
this.timeout = timeout;
this.clock = clock;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
}
/** The network calls run outside the lock, so a hanging identity provider holds up only its own callers. */
Endpoints endpoints() throws OidcException {
synchronized (this) {
if (endpoints != null && !endpointsAt.plus(CACHE).isBefore(clock.instant())) {
return endpoints;
}
}
String base = settings.issuerBase();
JsonNode doc = getJson(URI.create(base + "/.well-known/openid-configuration"));
if (!base.equals(doc.path("issuer").asText("").replaceAll("/+$", ""))) {
throw new OidcException(FAILED, "the discovery document names another issuer");
}
var fresh = new Endpoints(uri(doc, "authorization_endpoint"), uri(doc, "token_endpoint"), uri(doc, "jwks_uri"));
synchronized (this) {
endpoints = fresh;
endpointsAt = clock.instant();
}
return fresh;
}
@Override
public PublicKey key(String kid) throws OidcException {
synchronized (this) {
boolean stale = keysAt.plus(CACHE).isBefore(clock.instant());
boolean unknownAndRefetchable = !keys.containsKey(kid)
&& keysAt.plus(REFETCH_AFTER).isBefore(clock.instant());
if (!stale && !unknownAndRefetchable) {
return known(kid);
}
}
Map<String, PublicKey> fresh = parseKeys(getJson(endpoints().jwks()));
synchronized (this) {
keys = fresh;
keysAt = clock.instant();
return known(kid);
}
}
private PublicKey known(String kid) throws OidcException {
PublicKey key = keys.get(kid);
if (key == null) {
throw new OidcException(FAILED, "no signing key " + kid);
}
return key;
}
/** @return the ID token the identity provider issued for the code */
String exchange(String code, String verifier) throws OidcException {
var form = Map.of("grant_type", "authorization_code", "code", code,
"redirect_uri", settings.redirectUri().toString(), "code_verifier", verifier);
String credentials = encode(settings.clientId()) + ":" + encode(settings.clientSecret());
var request = HttpRequest.newBuilder(endpoints().token()).timeout(timeout)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", "Basic " + Base64.getEncoder()
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8)))
.POST(HttpRequest.BodyPublishers.ofString(form.entrySet().stream()
.map(e -> e.getKey() + "=" + encode(e.getValue())).collect(Collectors.joining("&"))))
.build();
JsonNode answer = send(request, "the token endpoint");
String idToken = answer.path("id_token").asText("");
if (idToken.isEmpty()) {
throw new OidcException(FAILED, "the token endpoint sent no id_token");
}
return idToken;
}
private JsonNode getJson(URI uri) throws OidcException {
return send(HttpRequest.newBuilder(uri).timeout(timeout).GET().build(), uri.getPath());
}
private JsonNode send(HttpRequest request, String what) throws OidcException {
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 500) {
throw new OidcException(UNAVAILABLE, what + " answered " + response.statusCode());
}
if (response.statusCode() != 200) {
throw new OidcException(FAILED, what + " answered " + response.statusCode());
}
return JSON.readTree(response.body());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new OidcException(UNAVAILABLE, what + " was interrupted", e);
} catch (IOException e) {
throw new OidcException(UNAVAILABLE, what + " is unreachable: " + e, e);
}
}
/** A key that cannot be read is skipped, so one odd entry does not lock everyone out. */
private static Map<String, PublicKey> parseKeys(JsonNode jwks) {
Map<String, PublicKey> keys = new HashMap<>();
for (JsonNode jwk : jwks.path("keys")) {
boolean signingRsa = "RSA".equals(jwk.path("kty").asText()) && !"enc".equals(jwk.path("use").asText());
if (signingRsa && jwk.hasNonNull("kid")) {
try {
keys.put(jwk.get("kid").asText(), KeyFactory.getInstance("RSA").generatePublic(
new RSAPublicKeySpec(integer(jwk, "n"), integer(jwk, "e"))));
} catch (GeneralSecurityException | IllegalArgumentException e) {
System.err.println("home-pump-backend: skipping unusable signing key " + jwk.get("kid").asText());
}
}
}
return keys;
}
private static BigInteger integer(JsonNode jwk, String field) {
return new BigInteger(1, Base64Url.decode(jwk.path(field).asText()));
}
private static URI uri(JsonNode doc, String field) throws OidcException {
String value = doc.path(field).asText("");
if (value.isEmpty()) {
throw new OidcException(FAILED, "the discovery document has no " + field);
}
return URI.create(value);
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,12 @@
package home.backend.adapters.auth;
import java.net.URI;
/** requiredRole is null when any user of the realm may sign in. */
public record OidcSettings(URI issuer, String clientId, String clientSecret, URI redirectUri, String requiredRole) {
/** The issuer without a trailing slash, the form tokens and discovery are compared in. */
String issuerBase() {
return issuer.toString().replaceAll("/+$", "");
}
}
@@ -0,0 +1,78 @@
package home.backend.adapters.auth;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/** Password hashes as {@code pbkdf2-sha256$<iterations>$<salt b64>$<hash b64>}: the cost travels with the hash. */
public final class PasswordHash {
private static final String SCHEME = "pbkdf2-sha256";
private static final int ITERATIONS = 600_000;
/** A file with a cheaper hash is refused, so a weak one cannot slip in. */
private static final int MIN_ITERATIONS = 310_000;
private static final int SALT_BYTES = 16;
private static final int HASH_BITS = 256;
private static final SecureRandom RANDOM = new SecureRandom();
private record Parsed(int iterations, byte[] salt, byte[] hash) {
}
private PasswordHash() {
}
public static String create(char[] password) {
byte[] salt = new byte[SALT_BYTES];
RANDOM.nextBytes(salt);
return String.join("$", SCHEME, String.valueOf(ITERATIONS), Base64.getEncoder().encodeToString(salt),
Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS, HASH_BITS)));
}
/** Compares in constant time. */
public static boolean matches(String stored, char[] password) {
Parsed parsed = parse(stored);
byte[] actual = derive(password, parsed.salt(), parsed.iterations(), parsed.hash().length * 8);
return MessageDigest.isEqual(parsed.hash(), actual);
}
/** @throws IllegalArgumentException when the text is not a hash this class made or accepts */
public static void requireValid(String stored) {
parse(stored);
}
private static Parsed parse(String stored) {
String[] parts = stored == null ? new String[0] : stored.split("\\$");
if (parts.length != 4 || !SCHEME.equals(parts[0])) {
throw new IllegalArgumentException("a password hash must look like " + SCHEME
+ "$<iterations>$<salt>$<hash>; create one with: hash-password");
}
try {
int iterations = Integer.parseInt(parts[1]);
byte[] salt = Base64.getDecoder().decode(parts[2]);
byte[] hash = Base64.getDecoder().decode(parts[3]);
if (iterations < MIN_ITERATIONS || salt.length < SALT_BYTES || hash.length < HASH_BITS / 8) {
throw new IllegalArgumentException("password hash is too weak: at least " + MIN_ITERATIONS
+ " iterations, 16 bytes of salt, a 32 byte hash");
}
return new Parsed(iterations, salt, hash);
} catch (IllegalArgumentException e) {
// NumberFormatException and a bad base64 are both IllegalArgumentException
throw new IllegalArgumentException("password hash is malformed or too weak (at least " + MIN_ITERATIONS
+ " iterations, 16 bytes of salt, a 32 byte hash)", e);
}
}
private static byte[] derive(char[] password, byte[] salt, int iterations, int bits) {
var spec = new PBEKeySpec(password, salt, iterations, bits);
try {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("PBKDF2WithHmacSHA256 is part of every JDK", e);
} finally {
spec.clearPassword();
}
}
}
@@ -0,0 +1,40 @@
package home.backend.adapters.auth;
import java.io.BufferedReader;
import java.io.Console;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/** {@code hash-password}: prints the hash for USERS_FILE; on a terminal the password is asked twice, unechoed. */
public final class PasswordHashCli {
private PasswordHashCli() {
}
/** @return the exit code */
public static int run(Console console, InputStream stdin, PrintStream out, PrintStream err) throws IOException {
char[] password;
if (console != null) {
password = console.readPassword("Password: ");
char[] again = console.readPassword("Again: ");
if (password == null || !Arrays.equals(password, again)) {
err.println("The passwords differ.");
return 1;
}
} else {
String line = new BufferedReader(new InputStreamReader(stdin, StandardCharsets.UTF_8)).readLine();
password = line == null ? null : line.toCharArray();
}
if (password == null || password.length == 0) {
err.println("No password given.");
return 1;
}
out.println(PasswordHash.create(password));
Arrays.fill(password, '\0');
return 0;
}
}
@@ -0,0 +1,19 @@
package home.backend.adapters.auth;
import java.time.Instant;
import java.util.regex.Pattern;
/** Who is signed in and how. {@code method} is {@code local}, {@code oidc} or {@code none} (AUTH_DISABLED). */
public record Session(String user, String method, Instant issuedAt) {
/** The name goes to the gateway's audit log as a header, so only plain characters are allowed. */
public static final String USER_PATTERN = "[\\w.@+-]{1,64}";
private static final Pattern USER = Pattern.compile(USER_PATTERN);
/** Every request is this one when authentication is switched off for development. */
static final Session OPEN = new Session("phone", "none", Instant.EPOCH);
public static boolean isValidUser(String name) {
return USER.matcher(name).matches();
}
}
@@ -0,0 +1,40 @@
package home.backend.adapters.auth;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
/** The session cookie: a signed token that holds the user, the method and when it was issued. */
final class Sessions {
static final String COOKIE = "hp_session";
private static final String PURPOSE = "session";
private final SignedTokens tokens;
private final Clock clock;
private final Duration lifetime;
Sessions(SignedTokens tokens, Clock clock, Duration lifetime) {
this.tokens = tokens;
this.clock = clock;
this.lifetime = lifetime;
}
String setCookie(String user, String method) {
String token = tokens.issue(PURPOSE, lifetime, Map.of("user", user, "method", method,
"iat", String.valueOf(clock.instant().getEpochSecond())));
return Cookies.set(COOKIE, token, lifetime, "/");
}
String clearCookie() {
return Cookies.clear(COOKIE, "/");
}
Optional<Session> read(String cookieHeader) {
return Cookies.read(cookieHeader, COOKIE).flatMap(token -> tokens.read(PURPOSE, token)).map(claims ->
new Session(claims.get("user"), claims.get("method"),
Instant.ofEpochSecond(Long.parseLong(claims.get("iat")))));
}
}
@@ -0,0 +1,71 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/** Tamper-proof, expiring text for cookies: {@code base64url(json).base64url(HMAC-SHA256)}. */
final class SignedTokens {
private static final ObjectMapper JSON = new ObjectMapper();
private static final TypeReference<Map<String, String>> CLAIMS = new TypeReference<>() {
};
private static final String HMAC = "HmacSHA256";
private final SecretKeySpec key;
private final Clock clock;
SignedTokens(byte[] secret, Clock clock) {
this.key = new SecretKeySpec(secret, HMAC);
this.clock = clock;
}
/** The purpose keeps a token made for one use (a session) from passing as another (a login in progress). */
String issue(String purpose, Duration lifetime, Map<String, String> claims) {
var all = new HashMap<>(claims);
all.put("purpose", purpose);
all.put("exp", String.valueOf(clock.instant().plus(lifetime).getEpochSecond()));
try {
String payload = Base64Url.encode(JSON.writeValueAsBytes(all));
return payload + "." + Base64Url.encode(sign(payload));
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
Optional<Map<String, String>> read(String purpose, String token) {
try {
String[] parts = token.split("\\.", -1);
if (parts.length != 2 || !MessageDigest.isEqual(sign(parts[0]), Base64Url.decode(parts[1]))) {
return Optional.empty();
}
Map<String, String> claims = JSON.readValue(Base64Url.decode(parts[0]), CLAIMS);
boolean valid = purpose.equals(claims.get("purpose"))
&& Long.parseLong(claims.get("exp")) > clock.instant().getEpochSecond();
return valid ? Optional.of(claims) : Optional.empty();
} catch (IOException | IllegalArgumentException e) {
return Optional.empty();
}
}
private byte[] sign(String payload) {
try {
Mac mac = Mac.getInstance(HMAC);
mac.init(key);
return mac.doFinal(payload.getBytes(StandardCharsets.US_ASCII));
} catch (GeneralSecurityException e) {
throw new IllegalStateException(HMAC + " is part of every JDK", e);
}
}
}
@@ -0,0 +1,151 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* The local accounts of USERS_FILE: {@code [{"username", "passwordHash"}]}. A missing file is an empty store
* (setup mode). Every change is written back atomically before it counts, under one lock.
*/
public final class UserStore {
public enum Removal { REMOVED, UNKNOWN, LAST }
private record Entry(String username, String passwordHash) {
}
private static final ObjectMapper JSON = new ObjectMapper();
/** What an unknown user is checked against, so that it costs as much as a wrong password. */
private static final String DECOY = PasswordHash.create(randomPassword());
private final Path file;
private Map<String, String> hashes;
UserStore(Path file, Map<String, String> hashes) {
this.file = file;
this.hashes = new LinkedHashMap<>(hashes);
}
/** @throws IllegalArgumentException when the file exists but is unreadable or holds a bad account */
public static UserStore load(Path file) {
Map<String, String> hashes = new LinkedHashMap<>();
if (Files.exists(file)) {
List<Entry> entries;
try {
entries = JSON.readValue(file.toFile(), new TypeReference<>() {
});
} catch (IOException e) {
throw new IllegalArgumentException("USERS_FILE " + file + " is not usable: " + e.getMessage(), e);
}
for (Entry entry : entries) {
if (entry.username() == null || !Session.isValidUser(entry.username())) {
throw new IllegalArgumentException("USERS_FILE: a username must match " + Session.USER_PATTERN);
}
PasswordHash.requireValid(entry.passwordHash());
if (hashes.put(entry.username(), entry.passwordHash()) != null) {
throw new IllegalArgumentException("USERS_FILE: duplicate username " + entry.username());
}
}
}
return new UserStore(file, hashes);
}
public synchronized boolean isEmpty() {
return hashes.isEmpty();
}
public synchronized boolean exists(String username) {
return hashes.containsKey(username);
}
public synchronized List<String> usernames() {
return List.copyOf(hashes.keySet());
}
public boolean authenticate(String username, char[] password) {
String stored;
synchronized (this) {
stored = hashes.get(username);
}
boolean matches = PasswordHash.matches(stored == null ? DECOY : stored, password);
return stored != null && matches;
}
/** The first account; false when there already is one, so only one setup can win. */
public synchronized boolean addFirst(String username, String hash) {
return hashes.isEmpty() && put(username, hash);
}
/** @return false when the name is taken */
public synchronized boolean add(String username, String hash) {
return !hashes.containsKey(username) && put(username, hash);
}
/** @return false when there is no such account */
public synchronized boolean changePassword(String username, String hash) {
return hashes.containsKey(username) && put(username, hash);
}
public synchronized Removal remove(String username) {
if (!hashes.containsKey(username)) {
return Removal.UNKNOWN;
}
if (hashes.size() == 1) {
return Removal.LAST;
}
var next = new LinkedHashMap<>(hashes);
next.remove(username);
write(next);
hashes = next;
return Removal.REMOVED;
}
private boolean put(String username, String hash) {
var next = new LinkedHashMap<>(hashes);
next.put(username, hash);
write(next);
hashes = next;
return true;
}
/** Temp file next to the target, synced, then renamed over it; the old file stays if anything fails. */
private void write(Map<String, String> accounts) {
Path directory = file.toAbsolutePath().getParent();
Path temp = null;
try {
temp = Files.createTempFile(directory, "users", ".tmp",
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
Files.write(temp, JSON.writerWithDefaultPrettyPrinter().writeValueAsBytes(accounts.entrySet().stream()
.map(e -> new Entry(e.getKey(), e.getValue())).toList()));
try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
channel.force(true);
}
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
if (temp != null) {
temp.toFile().delete();
}
throw new UncheckedIOException("cannot write USERS_FILE " + file, e);
}
}
private static char[] randomPassword() {
byte[] bytes = new byte[24];
new SecureRandom().nextBytes(bytes);
return Base64.getEncoder().encodeToString(bytes).toCharArray();
}
}
@@ -5,9 +5,15 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayBattery;
import home.backend.application.GatewayCommands; import home.backend.application.GatewayCommands;
import home.backend.application.GatewayRefresh;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.Command; import home.backend.domain.Command;
import home.backend.domain.CommandResult; import home.backend.domain.CommandResult;
import home.backend.domain.RefreshResult;
import home.backend.domain.RestoreResult; import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState; import home.backend.domain.SetpointState;
@@ -18,10 +24,11 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.Set; import java.util.Set;
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */ /** Calls the gateway's {@code /commands}, {@code /battery} and {@code /refresh} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands { public class GatewayHttpCommands implements GatewayCommands, GatewayBattery, GatewayRefresh {
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */ /** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502); private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
@@ -29,6 +36,20 @@ public class GatewayHttpCommands implements GatewayCommands {
/** Restore now: 200 restored, 404 nothing pending, 409 changed meanwhile, 429 too soon, 502 failed. */ /** 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); 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) @JsonInclude(JsonInclude.Include.NON_NULL)
private record Request(String setpoint, int value, Long minutes, Instant until) { private record Request(String setpoint, int value, Long minutes, Instant until) {
} }
@@ -47,6 +68,9 @@ public class GatewayHttpCommands implements GatewayCommands {
private final URI endpoint; private final URI endpoint;
private final URI renewEndpoint; private final URI renewEndpoint;
private final URI restoreEndpoint; private final URI restoreEndpoint;
private final URI refreshEndpoint;
private final URI reserveEndpoint;
private final URI scheduleEndpoint;
private final String token; private final String token;
private final Duration timeout; private final Duration timeout;
private final HttpClient http; private final HttpClient http;
@@ -56,6 +80,9 @@ public class GatewayHttpCommands implements GatewayCommands {
this.endpoint = baseUri.resolve("/commands"); this.endpoint = baseUri.resolve("/commands");
this.renewEndpoint = baseUri.resolve("/commands/renew"); this.renewEndpoint = baseUri.resolve("/commands/renew");
this.restoreEndpoint = baseUri.resolve("/commands/restore"); this.restoreEndpoint = baseUri.resolve("/commands/restore");
this.refreshEndpoint = baseUri.resolve("/refresh");
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
this.token = token; this.token = token;
this.timeout = timeout; this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build(); this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
@@ -90,6 +117,36 @@ public class GatewayHttpCommands implements GatewayCommands {
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed(); return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
} }
@Override
public RefreshResult refresh() throws IOException {
var request = request(refreshEndpoint).POST(HttpRequest.BodyPublishers.noBody());
return JSON.readValue(send(request, Set.of(200)), RefreshResult.class);
}
@Override
public Reserve reserve() throws IOException {
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
var slots = change.slots().stream().map(slot -> new SlotRequest(slot.slot(),
slot.from() == null ? null : slot.from().getHour() * 100 + slot.from().getMinute(),
slot.socPct(), slot.gridCharge())).toList();
return battery(scheduleEndpoint, new ScheduleRequest(change.enabled(), slots), requestedBy);
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
return battery(reserveEndpoint, new ReserveRequest(pct), requestedBy);
}
private BatteryResult battery(URI uri, Object body, String requestedBy) throws IOException {
var request = request(uri).header("Content-Type", "application/json").header("X-Requested-By", requestedBy)
.POST(HttpRequest.BodyPublishers.ofByteArray(JSON.writeValueAsBytes(body)));
return JSON.readValue(send(request, BATTERY_VERDICTS), BatteryResult.class);
}
private HttpRequest.Builder request() throws IOException { private HttpRequest.Builder request() throws IOException {
return request(endpoint); return request(endpoint);
} }
@@ -103,14 +160,15 @@ public class GatewayHttpCommands implements GatewayCommands {
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException { private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
try { try {
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofByteArray()); var built = request.build();
var response = http.send(built, HttpResponse.BodyHandlers.ofByteArray());
if (!accepted.contains(response.statusCode())) { if (!accepted.contains(response.statusCode())) {
throw new IOException("gateway /commands answered " + response.statusCode()); throw new IOException("gateway " + built.uri().getPath() + " answered " + response.statusCode());
} }
return response.body(); return response.body();
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new IOException("interrupted while calling gateway /commands", e); throw new IOException("interrupted while calling the gateway", e);
} }
} }
} }
@@ -9,12 +9,22 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import home.backend.adapters.auth.Authentication;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService; import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService; import home.backend.application.EnergyService;
import home.backend.application.GatewayRefresh;
import home.backend.application.HealthService; import home.backend.application.HealthService;
import home.backend.application.HeatPumpService; import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService; import home.backend.application.HistoryService;
import home.backend.application.ProgramService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService; import home.backend.application.SetpointService;
import home.backend.application.WeatherService;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.CommandResult.Status; import home.backend.domain.CommandResult.Status;
import home.backend.domain.PresetResult; import home.backend.domain.PresetResult;
import home.backend.domain.RestoreResult; import home.backend.domain.RestoreResult;
@@ -41,22 +51,33 @@ public class ApiServer {
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT) .disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS); .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
private record ReserveRequest(Integer pct) {
}
private record SetpointRequest(String setpoint, Integer value, Double hours) { private record SetpointRequest(String setpoint, Integer value, Double hours) {
} }
private record PresetRequest(String until) { /**
* {@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 UNREACHABLE = "gateway unreachable";
private static final String UNAVAILABLE = "battery changes are not available: gateway unreachable or not enabled";
private static final String WRITE_MARKER = "home-pump"; private static final String WRITE_MARKER = "home-pump";
private static final String PRESETS_PATH = "/api/presets/"; private static final String PRESETS_PATH = "/api/presets/";
private static final String SETPOINTS_PATH = "/api/setpoints/"; private static final String SETPOINTS_PATH = "/api/setpoints/";
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore"); private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
private static final String USER_HEADER = "Remote-User";
private static final String DEFAULT_USER = "phone";
private static final Pattern USER_NAME = Pattern.compile("[\\w.@+-]{1,64}");
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders(); private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */ /** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
@@ -64,25 +85,57 @@ public class ApiServer {
private static final int STOP_GRACE_SECONDS = 5; private static final int STOP_GRACE_SECONDS = 5;
private final HttpServer server; private final HttpServer server;
private final SessionFilter sessionFilter;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
/** Binds the port now; 0 picks a free one. */ /** Binds the port now; 0 picks a free one. */
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health, EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints) throws IOException { 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 = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(executor); server.setExecutor(executor);
sessionFilter = new SessionFilter(auth);
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010). // Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
context("/", exchange -> { open("/", exchange -> {
try (exchange) { try (exchange) {
exchange.sendResponseHeaders(404, -1); exchange.sendResponseHeaders(404, -1);
} }
}); });
var signIn = new AuthEndpoints(auth);
openRoute("/api/auth/config", signIn::config);
openRoute("/api/auth/login", signIn::login);
openRoute("/api/auth/logout", signIn::logout);
openRoute("/api/auth/me", signIn::me);
openRoute("/api/auth/setup", signIn::setup);
openRoute("/api/auth/users", signIn::users);
openRoute("/api/auth/password", signIn::password);
open("/api/auth/users/", signIn::deleteUser);
openRoute("/api/auth/oidc/start", signIn::oidcStart);
openRoute("/api/auth/oidc/callback", signIn::oidcCallback);
// The container healthcheck: no session, nothing in the answer.
openRoute("/api/live", exchange -> handle(exchange, Map.of("GET", () -> {
exchange.sendResponseHeaders(200, 2);
exchange.getResponseBody().write("ok".getBytes(StandardCharsets.US_ASCII));
})));
api("/api/heat-pump", query -> heatPump.current()); api("/api/heat-pump", query -> heatPump.current());
api("/api/energy", query -> energy.current()); api("/api/energy", query -> energy.current());
api("/api/energy/details", query -> energyDetails.current()); api("/api/energy/details", query -> energyDetails.current());
api("/api/battery", query -> battery.current());
route("/api/battery/schedule", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
route("/api/refresh", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postRefresh(exchange, refresh)))));
api("/api/pi-sensor", query -> piSensor.current());
api("/api/weather", query -> weather.current());
api("/api/health", query -> health.current()); api("/api/health", query -> health.current());
route("/api/history", exchange -> handleHistory(exchange, history)); route("/api/history", exchange -> handleHistory(exchange, history));
route("/api/audit", exchange -> handleAudit(exchange, audit));
route("/api/stats", exchange -> handleStats(exchange, stats));
route("/api/program", exchange -> handleProgram(exchange, program));
route("/api/setpoints", exchange -> handle(exchange, Map.of( route("/api/setpoints", exchange -> handle(exchange, Map.of(
"GET", () -> getSetpoints(exchange, setpoints), "GET", () -> getSetpoints(exchange, setpoints),
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints))))); "POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
@@ -101,9 +154,17 @@ public class ApiServer {
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints))))); "POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
} }
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
private void route(String path, HttpHandler handler) { private void route(String path, HttpHandler handler) {
context(path, exchange -> { context(path, exactly(path, handler));
}
private void openRoute(String path, HttpHandler handler) {
open(path, exactly(path, handler));
}
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
private static HttpHandler exactly(String path, HttpHandler handler) {
return exchange -> {
if (path.equals(exchange.getRequestURI().getPath())) { if (path.equals(exchange.getRequestURI().getPath())) {
handler.handle(exchange); handler.handle(exchange);
} else { } else {
@@ -111,10 +172,18 @@ public class ApiServer {
exchange.sendResponseHeaders(404, -1); exchange.sendResponseHeaders(404, -1);
} }
} }
}); };
} }
/** Needs a session. */
private void context(String path, HttpHandler handler) { private void context(String path, HttpHandler handler) {
var context = server.createContext(path, handler);
context.getFilters().add(SECURITY_HEADERS);
context.getFilters().add(sessionFilter);
}
/** Answers without a session. */
private void open(String path, HttpHandler handler) {
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS); server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
} }
@@ -150,7 +219,7 @@ public class ApiServer {
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot * A page on another site can make the browser send a simple POST with the user's cookies. It cannot
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three. * add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
*/ */
private static void guarded(HttpExchange exchange, Write write) throws IOException { static void guarded(HttpExchange exchange, Write write) throws IOException {
var headers = exchange.getRequestHeaders(); var headers = exchange.getRequestHeaders();
String origin = headers.getFirst("Origin"); String origin = headers.getFirst("Origin");
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) { if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
@@ -197,6 +266,45 @@ public class ApiServer {
} }
} }
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) private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
throws IOException { throws IOException {
try { try {
@@ -211,18 +319,21 @@ public class ApiServer {
throws IOException { throws IOException {
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length()); String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
try { try {
var request = body.length == 0 ? null : JSON.readValue(body, PresetRequest.class); var request = body.length == 0 ? new PresetRequest(null, null, null) : JSON.readValue(body, PresetRequest.class);
var until = request == null || request.until() == null ? null var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
: OffsetDateTime.parse(request.until()).toInstant(); var result = setpoints.applyPreset(id, until, request.hours(), request.values(),
var result = setpoints.applyPreset(id, until, user(exchange)); user(exchange));
if (result.isPresent()) { if (result.isPresent()) {
send(exchange, presetHttpStatus(result.get().status()), result.get()); send(exchange, presetHttpStatus(result.get().status()), result.get());
} else { } else {
send(exchange, 404, error("no such preset")); send(exchange, 404, error("no such preset"));
} }
} catch (SetpointService.InvalidHoursException e) {
send(exchange, 400, error("invalid_hours"));
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) { } catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs " send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
+ "an end time, else no body")); + "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
+ "for setpoints of the preset"));
} catch (IOException e) { } catch (IOException e) {
send(exchange, 503, error(UNREACHABLE)); send(exchange, 503, error(UNREACHABLE));
} }
@@ -236,6 +347,16 @@ public class ApiServer {
}; };
} }
private static int batteryHttpStatus(BatteryResult.Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case STALE -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private static int restoreHttpStatus(RestoreResult.Outcome outcome) { private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
return switch (outcome) { return switch (outcome) {
case RESTORED -> 200; case RESTORED -> 200;
@@ -254,9 +375,9 @@ public class ApiServer {
}; };
} }
/** The signed-in user, set by the session filter. */
private static String user(HttpExchange exchange) { private static String user(HttpExchange exchange) {
String name = exchange.getRequestHeaders().getFirst(USER_HEADER); return (String) exchange.getAttribute(SessionFilter.USER_ATTRIBUTE);
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
} }
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException { private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
@@ -272,15 +393,52 @@ public class ApiServer {
})); }));
} }
private interface Action { private void handleAudit(HttpExchange exchange, AuditService audit) throws IOException {
handle(exchange, Map.of("GET", () -> {
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
try {
send(exchange, 200, audit.audit(query.get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("audit unavailable"));
}
}));
}
private void handleStats(HttpExchange exchange, StatsService stats) throws IOException {
handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, stats.stats(parseQuery(exchange.getRequestURI().getRawQuery()).get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("stats unavailable"));
}
}));
}
private void handleProgram(HttpExchange exchange, ProgramService program) throws IOException {
handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, program.program(parseQuery(exchange.getRequestURI().getRawQuery()).get("days")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("program unavailable"));
}
}));
}
interface Action {
void run() throws IOException; void run() throws IOException;
} }
private interface Write { interface Write {
void run(byte[] body) throws IOException; void run(byte[] body) throws IOException;
} }
private static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException { static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
try (exchange) { try (exchange) {
Action action = byMethod.get(exchange.getRequestMethod()); Action action = byMethod.get(exchange.getRequestMethod());
if (action == null) { if (action == null) {
@@ -299,7 +457,7 @@ public class ApiServer {
} }
} }
private static void send(HttpExchange exchange, int status, Object body) throws IOException { static void send(HttpExchange exchange, int status, Object body) throws IOException {
byte[] bytes = JSON.writeValueAsBytes(body); byte[] bytes = JSON.writeValueAsBytes(body);
exchange.getResponseHeaders().set("Content-Type", "application/json"); exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.getResponseHeaders().set("Cache-Control", "no-store"); exchange.getResponseHeaders().set("Cache-Control", "no-store");
@@ -311,7 +469,7 @@ public class ApiServer {
return Map.of("error", message); return Map.of("error", message);
} }
private static Map<String, String> parseQuery(String raw) { static Map<String, String> parseQuery(String raw) {
Map<String, String> query = new HashMap<>(); Map<String, String> query = new HashMap<>();
if (raw == null) { if (raw == null) {
return query; return query;
@@ -0,0 +1,284 @@
package home.backend.adapters.http;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.auth.OidcException;
import home.backend.adapters.auth.Session;
import java.io.IOException;
import java.util.Map;
import java.util.regex.Pattern;
/** The endpoints under {@code /api/auth/}: sign-in needs no session, managing accounts does. */
final class AuthEndpoints {
private record LoginRequest(String username, String password) {
}
private record PasswordRequest(String current, @JsonProperty("new") String next) {
}
private static final Pattern DELETE_PATH = Pattern.compile("/api/auth/users/([^/]{1,64})/delete");
private static final ObjectMapper JSON = new ObjectMapper();
private static final int MAX_CREDENTIAL_LENGTH = 256;
private final Authentication auth;
AuthEndpoints(Authentication auth) {
this.auth = auth;
}
void config(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> ApiServer.send(exchange, 200,
Map.of("local", auth.localEnabled(), "oidc", auth.oidcEnabled(),
"setup", auth.setupNeeded()))));
}
void login(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> postLogin(exchange, body))));
}
void logout(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
String cookie = auth.clearedSessionCookie();
if (!cookie.isEmpty()) {
exchange.getResponseHeaders().add("Set-Cookie", cookie);
}
exchange.sendResponseHeaders(204, -1);
})));
}
void me(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> {
var session = auth.session(SessionFilter.cookies(exchange));
if (session.isPresent()) {
ApiServer.send(exchange, 200, user(session.get()));
} else {
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
}
}));
}
void oidcStart(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> {
if (!auth.oidcEnabled()) {
exchange.sendResponseHeaders(404, -1);
return;
}
try {
var start = auth.oidc().start();
exchange.getResponseHeaders().add("Set-Cookie", start.setCookie());
redirect(exchange, start.location().toString());
} catch (OidcException e) {
redirectWithError(exchange, e);
}
}));
}
void oidcCallback(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> {
if (!auth.oidcEnabled()) {
exchange.sendResponseHeaders(404, -1);
return;
}
exchange.getResponseHeaders().add("Set-Cookie", auth.oidc().clearCookie());
try {
String name = auth.oidc().callback(ApiServer.parseQuery(exchange.getRequestURI().getRawQuery()),
SessionFilter.cookies(exchange));
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(name, "oidc"));
redirect(exchange, "/");
} catch (OidcException e) {
redirectWithError(exchange, e);
}
}));
}
private void postLogin(HttpExchange exchange, byte[] body) throws IOException {
if (!auth.localEnabled()) {
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
return;
}
LoginRequest request = credentials(exchange, body);
if (request == null) {
return;
}
String ip = ClientIp.of(exchange, auth.proxyHops());
var login = auth.login(request.username(), request.password().toCharArray(), ip);
switch (login.status()) {
case OK -> {
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
}
case INVALID -> {
System.err.println("home-pump-backend: failed login from " + ip);
ApiServer.send(exchange, 401, Map.of("error", "invalid"));
}
case THROTTLED -> throttled(exchange, login);
}
}
/** The username and password of the body, or null after answering 400. */
private static LoginRequest credentials(HttpExchange exchange, byte[] body) throws IOException {
LoginRequest request;
try {
request = JSON.readValue(body, LoginRequest.class);
} catch (JsonProcessingException e) {
request = null;
}
if (request == null || request.username() == null || request.password() == null
|| request.username().length() > MAX_CREDENTIAL_LENGTH
|| request.password().length() > MAX_CREDENTIAL_LENGTH) {
ApiServer.send(exchange, 400, Map.of("error", "expected {\"username\", \"password\"}"));
return null;
}
return request;
}
private static void throttled(HttpExchange exchange, Authentication.Login login) throws IOException {
exchange.getResponseHeaders().set("Retry-After", String.valueOf(login.retryAfterSeconds()));
ApiServer.send(exchange, 429, Map.of("error", "throttled", "retryAfterSeconds", login.retryAfterSeconds()));
}
/** True when the name and password may become an account; else answers 400. */
private static boolean acceptable(HttpExchange exchange, String username, String password) throws IOException {
if (!Session.isValidUser(username)) {
ApiServer.send(exchange, 400, Map.of("error", "invalid_username"));
return false;
}
if (!Authentication.strongEnough(password)) {
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
return false;
}
return true;
}
void setup(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
if (!auth.localEnabled()) {
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
return;
}
LoginRequest request = credentials(exchange, body);
if (request == null) {
return;
}
if (!auth.setupNeeded()) {
ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
return;
}
if (!acceptable(exchange, request.username(), request.password())) {
return;
}
var result = auth.setup(request.username(), request.password().toCharArray(),
ClientIp.of(exchange, auth.proxyHops()));
switch (result.status()) {
case OK -> {
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
}
case INVALID -> ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
case THROTTLED -> throttled(exchange, result);
}
})));
}
/** Runs the action for a signed-in user of a configured local store; else answers 401 or 404. */
private void asUser(HttpExchange exchange, SignedIn action) throws IOException {
var session = auth.session(SessionFilter.cookies(exchange));
if (session.isEmpty()) {
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
} else if (!auth.localEnabled()) {
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
} else {
action.run(session.get());
}
}
private interface SignedIn {
void run(Session session) throws IOException;
}
void users(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of(
"GET", () -> asUser(exchange, session -> ApiServer.send(exchange, 200,
auth.usernames().stream().map(name -> Map.of("username", name)).toList())),
"POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
LoginRequest request = credentials(exchange, body);
if (request == null || !acceptable(exchange, request.username(), request.password())) {
return;
}
if (auth.addUser(session.user(), request.username(), request.password().toCharArray())) {
ApiServer.send(exchange, 200, Map.of("username", request.username()));
} else {
ApiServer.send(exchange, 409, Map.of("error", "exists"));
}
}))));
}
void password(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
PasswordRequest request;
try {
request = JSON.readValue(body, PasswordRequest.class);
} catch (JsonProcessingException e) {
request = null;
}
if (request == null || request.current() == null || request.next() == null
|| request.current().length() > MAX_CREDENTIAL_LENGTH
|| request.next().length() > MAX_CREDENTIAL_LENGTH) {
ApiServer.send(exchange, 400, Map.of("error", "expected {\"current\", \"new\"}"));
} else if (!Authentication.LOCAL.equals(session.method())) {
ApiServer.send(exchange, 403, Map.of("error", "local_only"));
} else if (!Authentication.strongEnough(request.next())) {
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
} else {
var result = auth.changePassword(session.user(), request.current().toCharArray(),
request.next().toCharArray(), ClientIp.of(exchange, auth.proxyHops()));
switch (result.status()) {
case OK -> exchange.sendResponseHeaders(204, -1);
case INVALID -> ApiServer.send(exchange, 401, Map.of("error", "invalid"));
case THROTTLED -> throttled(exchange, result);
}
}
}))));
}
void deleteUser(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
var path = DELETE_PATH.matcher(exchange.getRequestURI().getRawPath());
if (!path.matches()) {
exchange.sendResponseHeaders(404, -1);
return;
}
String name = path.group(1);
switch (auth.removeUser(session.user(), name)) {
case UNKNOWN -> ApiServer.send(exchange, 404, Map.of("error", "unknown_user"));
case LAST -> ApiServer.send(exchange, 409, Map.of("error", "last_user"));
case REMOVED -> {
if (name.equals(session.user())) {
exchange.getResponseHeaders().add("Set-Cookie", auth.clearedSessionCookie());
}
exchange.sendResponseHeaders(204, -1);
}
}
}))));
}
private static Map<String, String> user(Session session) {
return Map.of("user", session.user(), "method", session.method());
}
private static void redirectWithError(HttpExchange exchange, OidcException e) throws IOException {
System.err.println("home-pump-backend: OIDC sign-in failed: " + e.getMessage());
redirect(exchange, "/#login?error=" + e.reason().code());
}
private static void redirect(HttpExchange exchange, String location) throws IOException {
exchange.getResponseHeaders().set("Location", location);
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(302, -1);
}
}
@@ -0,0 +1,29 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.HttpExchange;
import java.util.Arrays;
import java.util.List;
/**
* The client's address for the login throttle. Each trusted proxy appends the address it saw to
* X-Forwarded-For, so the entry {@code hops} from the right is the first one no trusted proxy vouches for
* beyond that; anything the client put in front of it is ignored. Without enough entries the request did not
* come through the proxies, and the socket's address is used.
*/
final class ClientIp {
private ClientIp() {
}
static String of(HttpExchange exchange, int hops) {
List<String> values = exchange.getRequestHeaders().get("X-Forwarded-For");
if (values != null) {
List<String> chain = Arrays.stream(String.join(",", values).split(",")).map(String::trim).toList();
if (chain.size() >= hops && !chain.get(chain.size() - hops).isEmpty()) {
return chain.get(chain.size() - hops);
}
}
return exchange.getRemoteAddress().getAddress().getHostAddress();
}
}
@@ -0,0 +1,45 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.Filter;
import com.sun.net.httpserver.HttpExchange;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.auth.Session;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
/** Lets a request through only with a valid session, and hands the user's name on to the handlers. */
final class SessionFilter extends Filter {
static final String USER_ATTRIBUTE = "user";
private final Authentication auth;
SessionFilter(Authentication auth) {
this.auth = auth;
}
@Override
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
Optional<Session> session = auth.session(cookies(exchange));
if (session.isPresent()) {
exchange.setAttribute(USER_ATTRIBUTE, session.get().user());
chain.doFilter(exchange);
} else {
try (exchange) {
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
}
}
}
static String cookies(HttpExchange exchange) {
var values = exchange.getRequestHeaders().get("Cookie");
return values == null ? null : String.join("; ", values);
}
@Override
public String description() {
return "session check";
}
}
@@ -0,0 +1,88 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.application.AuditRepository;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** The gateway's audit measurements (S3) read from InfluxDB; never written. */
public final class InfluxAuditRepository implements AuditRepository {
private static final ObjectMapper JSON = new ObjectMapper();
private final InfluxClient influx;
public InfluxAuditRepository(InfluxSettings settings) {
this.influx = new InfluxClient(settings);
}
@Override
public List<Row> heatPump(Instant from, Instant to) throws IOException {
return rows("Dom_Pompa", "setpoint_audit", "setpoint", from, to);
}
@Override
public List<Row> battery(Instant from, Instant to) throws IOException {
return rows("electricity", "deye_settings_audit", "register", from, to);
}
private List<Row> rows(String database, String measurement, String targetTag, Instant from, Instant to)
throws IOException {
String query = "SELECT * FROM \"" + measurement + "\" WHERE time >= " + from.toEpochMilli()
+ "ms AND time < " + to.toEpochMilli() + "ms ORDER BY time DESC";
var rows = new ArrayList<Row>();
for (JsonNode result : results(influx.query(database, query))) {
for (JsonNode series : result.path("series")) {
for (JsonNode values : series.path("values")) {
rows.add(row(series.path("columns"), values, targetTag));
}
}
}
return rows;
}
private static JsonNode results(String body) throws IOException {
JsonNode root;
try {
root = JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
failOnError(root);
for (JsonNode result : root.path("results")) {
failOnError(result);
}
return root.path("results");
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
private static Row row(JsonNode columns, JsonNode values, String targetTag) {
Map<String, JsonNode> byName = new HashMap<>();
for (int i = 0; i < columns.size(); i++) {
byName.put(columns.get(i).asText(), values.get(i));
}
return new Row(Instant.ofEpochMilli(byName.get("time").asLong()), text(byName.get(targetTag)),
text(byName.get("requested_by")), text(byName.get("status")),
number(byName.get("old_value")), number(byName.get("new_value")));
}
private static String text(JsonNode node) {
return node == null || node.isNull() ? "" : node.asText();
}
private static Double number(JsonNode node) {
return node == null || !node.isNumber() ? null : node.asDouble();
}
}
@@ -0,0 +1,47 @@
package home.backend.adapters.influx;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
/** Read-only queries to InfluxDB 1.x over its HTTP {@code /query} endpoint; times come back in epoch ms. */
final class InfluxClient {
private final InfluxSettings settings;
private final HttpClient http;
InfluxClient(InfluxSettings settings) {
this.settings = settings;
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
}
String query(String database, String query) throws IOException {
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
.timeout(settings.requestTimeout())
.GET()
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("InfluxDB request interrupted", e);
}
}
private URI queryUri(String database, String query) {
String base = settings.baseUri().toString().replaceAll("/+$", "");
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -1,16 +1,12 @@
package home.backend.adapters.influx; package home.backend.adapters.influx;
import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryRepository; import home.backend.application.HistoryRepository;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point; import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series; import home.backend.domain.Series;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
@@ -20,14 +16,12 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */ /** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
public final class InfluxHistoryRepository implements HistoryRepository { public final class InfluxHistoryRepository implements HistoryRepository, HeatPumpSamples {
private final InfluxSettings settings; private final InfluxClient influx;
private final HttpClient http;
public InfluxHistoryRepository(InfluxSettings settings) { public InfluxHistoryRepository(InfluxSettings settings) {
this.settings = settings; this.influx = new InfluxClient(settings);
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
} }
@Override @Override
@@ -57,39 +51,19 @@ public final class InfluxHistoryRepository implements HistoryRepository {
.toList(); .toList();
} }
@Override
public List<HeatPumpSample> samples(Instant from, Instant to) throws IOException {
String query = InfluxSamples.query(from.toEpochMilli(), to.toEpochMilli());
return InfluxSamples.parse(influx.query(SeriesSources.LEGACY, query));
}
private Map<Long, Double> part(Source source, Instant from, Instant to, long stepMinutes) throws IOException { private Map<Long, Double> part(Source source, Instant from, Instant to, long stepMinutes) throws IOException {
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes); String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
return InfluxResponse.parse(fetch(source.database(), query)); return InfluxResponse.parse(influx.query(source.database(), query));
} }
private static long stepMinutes(Instant from, Instant to, int maxPoints) { private static long stepMinutes(Instant from, Instant to, int maxPoints) {
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints); long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis())); return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
} }
private String fetch(String database, String query) throws IOException {
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
.timeout(settings.requestTimeout())
.GET()
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("InfluxDB request interrupted", e);
}
}
private URI queryUri(String database, String query) {
String base = settings.baseUri().toString().replaceAll("/+$", "");
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
} }
@@ -0,0 +1,51 @@
package home.backend.adapters.influx;
import home.backend.application.PvEnergyRepository;
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Map;
import java.util.TreeMap;
/** Daily PV energy: the Deye's and the Fronius's own day counters, each read as the day's maximum. */
public final class InfluxPvEnergy implements PvEnergyRepository {
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
private static final String DATABASE = "electricity";
private final InfluxClient influx;
public InfluxPvEnergy(InfluxSettings settings) {
this.influx = new InfluxClient(settings);
}
@Override
public Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException {
Map<LocalDate, Double> deye = dailyMax("deye", "pv_today_kwh", from, to);
Map<LocalDate, Double> fronius = dailyMax("fronius", "energy_day_wh", from, to);
Map<LocalDate, Double> total = new TreeMap<>();
deye.forEach((day, kwh) -> {
Double wh = fronius.get(day);
if (wh != null) {
total.put(day, kwh + wh / 1000);
}
});
return total;
}
private Map<LocalDate, Double> dailyMax(String measurement, String field, LocalDate from, LocalDate to)
throws IOException {
String query = "SELECT max(\"" + field + "\") FROM \"" + measurement + "\" WHERE time >= "
+ millis(from) + "ms AND time < " + millis(to) + "ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')";
Map<LocalDate, Double> days = new TreeMap<>();
InfluxResponse.parse(influx.query(DATABASE, query)).forEach((start, max) ->
days.put(Instant.ofEpochMilli(start).atZone(ZONE).toLocalDate(), max));
return days;
}
private static long millis(LocalDate day) {
return day.atStartOfDay(ZONE).toInstant().toEpochMilli();
}
}
@@ -0,0 +1,74 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.Periods;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/** The raw {@code heat_pump} rows of an InfluxDB 1.x query. */
final class InfluxSamples {
private static final ObjectMapper JSON = new ObjectMapper();
private InfluxSamples() {
}
static String query(long fromMs, long toMs) {
return "SELECT output_compressor, output_cwu, output_co, outdoor_temp, cwu_temp,"
+ " program, target_room_temp, target_cwu_temp, setpoint_co_economy, setpoint_co_comfort,"
+ " setpoint_cwu_economy, setpoint_cwu_comfort"
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
}
/** The rows of {@link #query}, as returned (ascending); empty when there is no data. */
static List<HeatPumpSample> parse(String body) throws IOException {
JsonNode root;
try {
root = JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
failOnError(root);
List<HeatPumpSample> samples = new ArrayList<>();
for (JsonNode result : root.path("results")) {
failOnError(result);
for (JsonNode series : result.path("series")) {
for (JsonNode row : series.path("values")) {
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
number(row, 1), number(row, 2), number(row, 3),
number(row, 4), number(row, 5), periods(row)));
}
}
}
return samples;
}
private static Periods periods(JsonNode row) {
var targets = new Targets(number(row, 7), null, number(row, 8));
var setpoints = new Setpoints(whole(row, 9), whole(row, 10), whole(row, 11), whole(row, 12));
return Periods.of(whole(row, 6), targets, setpoints);
}
private static Integer whole(JsonNode row, int column) {
Double value = number(row, column);
return value == null ? null : (int) Math.round(value);
}
private static Double number(JsonNode row, int column) {
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
}
@@ -8,7 +8,7 @@ import java.util.Map;
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */ /** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
final class SeriesSources { final class SeriesSources {
private static final String LEGACY = "Dom_Pompa"; static final String LEGACY = "Dom_Pompa";
private static final String ELECTRICITY = "electricity"; private static final String ELECTRICITY = "electricity";
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries( private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
@@ -0,0 +1,42 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/** GET of a public JSON document, with a timeout on connecting and on the whole request. */
final class HttpJson {
private static final ObjectMapper JSON = new ObjectMapper();
private final HttpClient http;
private final Duration timeout;
HttpJson(Duration timeout) {
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
this.timeout = timeout;
}
JsonNode get(URI uri) throws IOException {
HttpRequest request = HttpRequest.newBuilder(uri).timeout(timeout).GET().build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException(uri.getHost() + " answered HTTP " + response.statusCode());
}
return JSON.readTree(response.body());
} catch (JsonProcessingException e) {
throw new IOException(uri.getHost() + " returned invalid JSON: " + e.getOriginalMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("request to " + uri.getHost() + " interrupted", e);
}
}
}
@@ -0,0 +1,76 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.databind.JsonNode;
import home.backend.application.WarningSource;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.net.URI;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* IMGW's public list of meteorological warnings, kept to those naming one of our TERYT areas. A warning
* that is malformed (no degree or times) is left out rather than failing the others.
*/
public final class ImgwClient implements WarningSource {
private static final ZoneId POLAND = ZoneId.of("Europe/Warsaw");
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final HttpJson http;
private final URI uri;
private final List<String> teryt;
public ImgwClient(URI baseUri, WeatherSettings place, Duration timeout) {
this.http = new HttpJson(timeout);
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/api/data/warningsmeteo");
this.teryt = place.teryt();
}
@Override
public List<Warning> warnings() throws IOException {
JsonNode root = http.get(uri);
if (!root.isArray()) {
throw new IOException("IMGW returned no list of warnings");
}
List<Warning> ours = new ArrayList<>();
for (JsonNode item : root) {
if (covers(item.path("teryt"))) {
warning(item).ifPresent(ours::add);
}
}
return ours;
}
private boolean covers(JsonNode codes) {
for (JsonNode code : codes) {
if (teryt.contains(code.asText())) {
return true;
}
}
return false;
}
private static Optional<Warning> warning(JsonNode item) {
try {
return Optional.of(new Warning(item.path("nazwa_zdarzenia").asText(),
Integer.parseInt(item.path("stopien").asText()),
time(item.path("obowiazuje_od")), time(item.path("obowiazuje_do")),
item.path("tresc").asText()));
} catch (NumberFormatException | DateTimeException e) {
return Optional.empty();
}
}
private static OffsetDateTime time(JsonNode text) {
return LocalDateTime.parse(text.asText(), TIME).atZone(POLAND).toOffsetDateTime();
}
}
@@ -0,0 +1,68 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.databind.JsonNode;
import home.backend.application.ForecastSource;
import home.backend.domain.Forecast;
import home.backend.domain.Forecast.DailyForecast;
import java.io.IOException;
import java.net.URI;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Map;
import java.util.TreeMap;
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 4, and the temperature now. */
public final class OpenMeteoClient implements ForecastSource {
private static final String DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,"
+ "shortwave_radiation_sum,sunshine_duration";
private final HttpJson http;
private final URI uri;
public OpenMeteoClient(URI baseUri, WeatherSettings place, Duration timeout) {
this.http = new HttpJson(timeout);
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/v1/forecast?latitude="
+ place.latitude() + "&longitude=" + place.longitude() + "&daily=" + DAILY
+ "&current=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();
}
}
@@ -69,7 +69,7 @@ public class EnergyDetailsService {
phases("grid_power_l%d_w", "Moc", "W", 0), phases("grid_power_l%d_w", "Moc", "W", 0),
phases("grid_voltage_l%d_v", "Napięcie", "V", 1), phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)), field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
group("Odbiór domu", group("Wyjście zapasowe falownika",
field("load_power_w", "Moc razem", "W", 0), field("load_power_w", "Moc razem", "W", 0),
phases("load_power_l%d_w", "Moc", "W", 0)), phases("load_power_l%d_w", "Moc", "W", 0)),
group("Panele PV", group("Panele PV",
@@ -81,14 +81,14 @@ public class EnergyDetailsService {
field("pv2_current_a", "PV2 prąd", "A", 1)), field("pv2_current_a", "PV2 prąd", "A", 1)),
group("Dziś", group("Dziś",
field("pv_today_kwh", "Produkcja PV", "kWh", 1), field("pv_today_kwh", "Produkcja PV", "kWh", 1),
field("load_today_kwh", "Zużycie domu", "kWh", 1), field("load_today_kwh", "Wyjście zapasowe", "kWh", 1),
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1), field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1), field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1), field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)), field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
group("Od początku", group("Od początku",
field("pv_total_kwh", "Produkcja PV", "kWh", 0), field("pv_total_kwh", "Produkcja PV", "kWh", 0),
field("load_total_kwh", "Zużycie domu", "kWh", 0), field("load_total_kwh", "Wyjście zapasowe", "kWh", 0),
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0), field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0), field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0), field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
@@ -11,9 +11,20 @@ import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream; import java.util.stream.Stream;
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */ /**
* F7: house electricity from the Deye inverter, the Fronius PV inverter and the house meter.
* The Deye "load" registers cover only its backup output, so the house is a balance:
* <ul>
* <li>now = Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, where grid is the
* house meter's total active power when it is live, else the Deye's grid power; floored at 0, null when
* any of those readings is missing or stale;</li>
* <li>today = Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged
* (Deye day counters), null when any is missing.</li>
* </ul>
*/
public class EnergyService { public class EnergyService {
private final GatewayClient gateway; private final GatewayClient gateway;
@@ -30,17 +41,42 @@ public class EnergyService {
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant()); Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
Map<String, Double> d = deye.fields(); Map<String, Double> d = deye.fields();
Map<String, Double> f = fronius.fields(); Map<String, Double> f = fronius.fields();
Map<String, Double> meter = SnapshotFields.live(snapshot, "house-meter", "PRAD2", clock.instant()).fields();
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null; Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)), Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), d.get("load_today_kwh")); d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), houseKwh(d, froniusKwh));
List<Source> sources = List.of(deye.source(), fronius.source()); List<Source> sources = List.of(deye.source(), fronius.source());
return new EnergyView(oldest(sources), sources, return new EnergyView(oldest(sources), sources,
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")), sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"), d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
housePowerW(d, f, meter.getOrDefault("total_power_active", d.get("grid_power_w"))),
d.get("load_power_w"), today); d.get("load_power_w"), today);
} }
private static Double housePowerW(Map<String, Double> deye, Map<String, Double> fronius, Double gridW) {
Double pv1 = deye.get("pv1_power_w");
Double pv2 = deye.get("pv2_power_w");
Double pvFronius = fronius.get("pv_power_w");
Double battery = deye.get("battery_power_w");
if (Stream.of(pv1, pv2, pvFronius, battery, gridW).anyMatch(Objects::isNull)) {
return null;
}
return Math.max(0, pv1 + pv2 + pvFronius + battery + gridW);
}
private static Double houseKwh(Map<String, Double> deye, Double froniusKwh) {
Double pv = deye.get("pv_today_kwh");
Double bought = deye.get("grid_bought_today_kwh");
Double sold = deye.get("grid_sold_today_kwh");
Double discharged = deye.get("battery_discharge_today_kwh");
Double charged = deye.get("battery_charge_today_kwh");
if (Stream.of(pv, froniusKwh, bought, sold, discharged, charged).anyMatch(Objects::isNull)) {
return null;
}
return round(Math.max(0, pv + froniusKwh + bought - sold + discharged - charged));
}
private static Instant oldest(List<Source> sources) { private static Instant oldest(List<Source> sources) {
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null); return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
} }
@@ -0,0 +1,11 @@
package home.backend.application;
import home.backend.domain.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.RefreshResult;
import java.io.IOException;
/** Asks the gateway for fresh readings; it decides which devices may be polled again yet. */
public interface GatewayRefresh {
RefreshResult refresh() throws IOException;
}
@@ -0,0 +1,13 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
/** The heat pump polls stored in InfluxDB, oldest first. */
public interface HeatPumpSamples {
List<HeatPumpSample> samples(Instant from, Instant to) throws IOException;
}
@@ -8,6 +8,7 @@ import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.State; import home.backend.domain.HeatPumpView.State;
import home.backend.domain.HeatPumpView.Targets; import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.HeatPumpView.Temperatures; import home.backend.domain.HeatPumpView.Temperatures;
import home.backend.domain.Periods;
import java.io.IOException; import java.io.IOException;
import java.time.Clock; import java.time.Clock;
@@ -43,7 +44,11 @@ public class HeatPumpService {
private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) { private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) {
Integer alarmCode = whole(f, "alarm_code"); Integer alarmCode = whole(f, "alarm_code");
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"), on(f, "output_heater"), var targets = new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
tenths(f, "target_cwu_temp"));
var setpoints = new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort"));
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"),
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode), on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"), new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
on(f, "output_ground"), on(f, "output_cwu")), on(f, "output_ground"), on(f, "output_cwu")),
@@ -51,10 +56,7 @@ public class HeatPumpService {
tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"), tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"), tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
tenths(f, "outdoor_avg_temp")), tenths(f, "outdoor_avg_temp")),
new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"), targets, setpoints, Periods.of(whole(f, "program"), targets, setpoints));
tenths(f, "target_cwu_temp")),
new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort")));
} }
/** Codes of register 4011 as the vendor app names them; unverified on our device. */ /** Codes of register 4011 as the vendor app names them; unverified on our device. */
@@ -0,0 +1,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;
}
@@ -17,11 +17,24 @@ import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */ /** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
public class SetpointService { public class SetpointService {
/** Longest timed preset the user may choose; the gateway's own limit is higher. */
public static final int MAX_PRESET_HOURS = 4;
/** The chosen hours are not a whole number from 1 to {@link #MAX_PRESET_HOURS}, or the preset is not timed. */
public static class InvalidHoursException extends IllegalArgumentException {
InvalidHoursException(String message) {
super(message);
}
}
private final GatewayCommands gateway; private final GatewayCommands gateway;
private final List<Preset> presets; private final List<Preset> presets;
@@ -50,9 +63,16 @@ public class SetpointService {
* IOException only when no change was attempted; once writing starts, trouble is reported per change. * IOException only when no change was attempted; once writing starts, trouble is reported per change.
* *
* @param until the end the user chose; required by a held preset, not allowed for any other * @param until the end the user chose; required by a held preset, not allowed for any other
* @throws IllegalArgumentException when {@code until} does not match what the preset needs * @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, String user) throws IOException { 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(); Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
if (preset.isEmpty()) { if (preset.isEmpty()) {
return Optional.empty(); return Optional.empty();
@@ -61,8 +81,9 @@ public class SetpointService {
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time" throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
: "preset " + id + " has no end time to choose"); : "preset " + id + " has no end time to choose");
} }
List<Change> changes = preset.get().changes(); List<Change> changes = withHours(withValues(preset.get().changes(), values), hours);
List<Setpoint> limits = gateway.setpoints().setpoints(); List<Setpoint> limits = gateway.setpoints().setpoints();
requireWithinLimits(values, limits);
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList(); List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) { if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
return Optional.of(PresetResult.of(checked)); return Optional.of(PresetResult.of(checked));
@@ -70,6 +91,38 @@ public class SetpointService {
return Optional.of(PresetResult.of(run(changes, until, user))); 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) { private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
List<ChangeResult> results = new ArrayList<>(); List<ChangeResult> results = new ArrayList<>();
for (Change change : changes) { for (Change change : changes) {
@@ -26,8 +26,13 @@ final class SnapshotFields {
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */ /** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) { static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
return stale(device, reading, now, MAX_AGE);
}
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now,
Duration maxAge) {
return device.isEmpty() || !device.get().up() || reading.isEmpty() return device.isEmpty() || !device.get().up() || reading.isEmpty()
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 0; || Duration.between(reading.get().time(), now).compareTo(maxAge) > 0;
} }
/** One device's fields; empty when it is stale, so nothing old is shown as current. */ /** One device's fields; empty when it is stale, so nothing old is shown as current. */
@@ -35,8 +40,12 @@ final class SnapshotFields {
} }
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now) { 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); Optional<Measurement> reading = find(snapshot, device, measurement);
boolean stale = stale(device(snapshot, device), reading, now); boolean stale = stale(device(snapshot, device), reading, now, maxAge);
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale); Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
return new Live(source, stale ? Map.of() : reading.get().fields()); return new Live(source, stale ? Map.of() : reading.get().fields());
} }
@@ -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) {
}
}
@@ -5,17 +5,18 @@ import java.util.List;
/** /**
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge / * The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
* − charge. Values that come only from a stale source are null. {@code time} is the oldest source * − charge. {@code housePowerW} is the whole house by balance; {@code backupOutputW} only the Deye's
* backup (EPS) output. Values that come only from a stale source are null. {@code time} is the oldest source
* time (null when none was read); {@code sources} lists Deye and Fronius with their own time and freshness. * time (null when none was read); {@code sources} lists Deye and Fronius with their own time and freshness.
*/ */
public record EnergyView(Instant time, List<Source> sources, Double pvPowerW, Double gridPowerW, public record EnergyView(Instant time, List<Source> sources, Double pvPowerW, Double gridPowerW,
Double batteryPowerW, Double batterySocPct, Double loadPowerW, Today today) { Double batteryPowerW, Double batterySocPct, Double housePowerW, Double backupOutputW, Today today) {
/** One inverter; time is null and stale true when the gateway has no reading of it. */ /** One inverter; time is null and stale true when the gateway has no reading of it. */
public record Source(String name, Instant time, boolean stale) { public record Source(String name, Instant time, boolean stale) {
} }
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */ /** Day totals in kWh since local midnight; PV adds Deye and Fronius, house is the energy balance. */
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) { public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double houseKwh) {
} }
} }
@@ -0,0 +1,13 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.Map;
/** What the forecast service says: the temperature now and a record per calendar day, past days included. */
public record Forecast(Double temperature, Map<LocalDate, DailyForecast> days) {
/** Radiation in MJ/m² per day, sunshine in seconds; any value may be missing. */
public record DailyForecast(Integer weatherCode, Double tMax, Double tMin, Double precipitationMm,
Double radiationMj, Double sunshineSeconds) {
}
}
@@ -0,0 +1,8 @@
package home.backend.domain;
import java.time.Instant;
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co,
Double outdoorTemp, Double cwuTemp, Periods periods) {
}
@@ -6,9 +6,9 @@ import java.time.Instant;
* What the heat pump does now (F1). Without a reading from the gateway {@code time} and the values are * What the heat pump does now (F1). Without a reading from the gateway {@code time} and the values are
* null; {@code stale} is true when the device is down or the reading is older than 2 × 300 s. * null; {@code stale} is true when the device is down or the reading is older than 2 × 300 s.
*/ */
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent, boolean heaterOn, public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent,
boolean alarm, Integer alarmCode, String alarmText, Pumps pumps, Temperatures temperatures, boolean alarm, Integer alarmCode, String alarmText, Pumps pumps, Temperatures temperatures,
Targets targets, Setpoints setpoints) { Targets targets, Setpoints setpoints, Periods periods) {
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */ /** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH } public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
@@ -0,0 +1,13 @@
package home.backend.domain;
import com.fasterxml.jackson.annotation.JsonValue;
/** Which period of the weekly program a circuit is in, read from its target (see {@link Periods}). */
public enum Period {
COMFORT, ECONOMY, UNKNOWN, OVERRIDE;
@JsonValue
public String id() {
return name().toLowerCase();
}
}
@@ -0,0 +1,50 @@
package home.backend.domain;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.Targets;
/**
* The period of CWU and room, from the target the controller works to now (verified on history,
* docs/frisko/history-2026-10.md). Only the weekly program (register 4226 = 1) switches between
* economy and comfort; any other program overrides both. {@code programText} names such a program.
*/
public record Periods(Period cwu, Period room, Integer program, String programText) {
public static final Periods UNKNOWN = new Periods(Period.UNKNOWN, Period.UNKNOWN, null, null);
private static final int WEEKLY = 1;
private static final double SAME_TARGET = 0.05;
public static Periods of(Integer program, Targets targets, Setpoints setpoints) {
if (program == null) {
return UNKNOWN;
}
return new Periods(
period(program, targets.cwu(), setpoints.cwuEconomy(), setpoints.cwuComfort()),
period(program, targets.room(), setpoints.coEconomy(), setpoints.coComfort()),
program, program == WEEKLY ? null : programText(program));
}
private static Period period(int program, Double target, Integer economy, Integer comfort) {
if (program != WEEKLY) {
return Period.OVERRIDE;
}
if (target == null || economy == null || comfort == null || economy.equals(comfort)) {
return Period.UNKNOWN;
}
if (Math.abs(target - comfort) < SAME_TARGET) {
return Period.COMFORT;
}
return Math.abs(target - economy) < SAME_TARGET ? Period.ECONOMY : Period.UNKNOWN;
}
private static String programText(int program) {
return switch (program) {
case 0 -> "Ferie";
case 2 -> "Party";
case 3 -> "Rozruch";
case 4 -> "Stop";
case 5 -> "Dezynfekcja";
default -> "Program " + program;
};
}
}
@@ -0,0 +1,11 @@
package home.backend.domain;
import java.time.Instant;
/**
* The BME280 sensor on the Raspberry Pi: °C, % and hPa. When it is stale (device down, no reading
* or older than 2 × 300 s) the values are null and {@code stale} is true; {@code time} is null
* without any reading.
*/
public record PiSensorView(Instant time, boolean stale, Double temperature, Double humidity, Double pressure) {
}
@@ -0,0 +1,35 @@
package home.backend.domain;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.List;
/**
* The weekly program as the history shows it (F6): per weekday the comfort intervals of CWU and room.
* Nothing is read from the controller's program registers. {@code weekdays} runs Monday (1) to Sunday (7).
*/
public record ProgramView(int days, List<Weekday> weekdays) {
public record Weekday(int day, Circuit cwu, Circuit room) {
}
/**
* {@code observedDays}: days of this weekday with known periods; {@code agreeingDays}: how many of them
* show {@code comfort} (within 5 minutes).
*/
public record Circuit(Confidence confidence, int observedDays, int agreeingDays, List<Interval> comfort) {
}
/** Local times "HH:MM"; an interval that runs to midnight ends at "24:00". */
public record Interval(String from, String to) {
}
public enum Confidence {
CONFIRMED, SEEN_ONCE, NO_DATA;
@JsonValue
public String id() {
return name().toLowerCase();
}
}
}
@@ -0,0 +1,32 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.Map;
/**
* The PV output per unit of radiation, found from past days instead of the panels' tilt and azimuth:
* measured kWh over forecast MJ/m², summed over the days that have both.
*/
public final class PvCalibration {
static final int MIN_DAYS = 5;
private PvCalibration() {
}
/** Null with fewer than {@value MIN_DAYS} days having both values or no radiation at all. */
public static Double factor(Map<LocalDate, Double> measuredKwh, Map<LocalDate, Double> radiationMj) {
double kwh = 0;
double mj = 0;
int days = 0;
for (var day : measuredKwh.entrySet()) {
Double radiation = radiationMj.get(day.getKey());
if (radiation != null) {
kwh += day.getValue();
mj += radiation;
days++;
}
}
return days < MIN_DAYS || mj <= 0 ? null : kwh / mj;
}
}
@@ -0,0 +1,10 @@
package home.backend.domain;
import java.util.List;
/** The gateway's answer to a refresh: the devices it will poll again, and those polled too recently. */
public record RefreshResult(List<String> requested, List<Skipped> skipped) {
public record Skipped(String device, long retryInSeconds) {
}
}
@@ -0,0 +1,19 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.List;
/**
* Heat pump work per local day (F2). Hours are approximated from the polls, starts are counted
* between polls, so short cycles are missed. A day without polls has null values.
*/
public record StatsView(String range, List<Day> days, Totals totals) {
public record Day(LocalDate date, Double compressorHours, Integer compressorStarts, Double cwuHours,
Double coHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
public record Totals(double compressorHours, int compressorStarts, double cwuHours, double coHours,
Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
}
@@ -0,0 +1,37 @@
package home.backend.domain;
/** WMO weather interpretation codes as Polish text. */
public final class WeatherCodes {
private WeatherCodes() {
}
public static String describe(Integer code) {
if (code == null) {
return "Brak danych";
}
return switch (code) {
case 0 -> "Bezchmurnie";
case 1 -> "Przeważnie słonecznie";
case 2 -> "Częściowe zachmurzenie";
case 3 -> "Pochmurno";
case 45, 48 -> "Mgła";
case 51, 53, 55 -> "Mżawka";
case 56, 57 -> "Marznąca mżawka";
case 61 -> "Słaby deszcz";
case 63 -> "Deszcz";
case 65 -> "Silny deszcz";
case 66, 67 -> "Marznący deszcz";
case 71 -> "Słaby śnieg";
case 73 -> "Śnieg";
case 75 -> "Intensywny śnieg";
case 77 -> "Ziarna śnieżne";
case 80, 81 -> "Przelotny deszcz";
case 82 -> "Ulewa";
case 85, 86 -> "Przelotny śnieg";
case 95 -> "Burza";
case 96, 99 -> "Burza z gradem";
default -> "Brak danych";
};
}
}
@@ -0,0 +1,27 @@
package home.backend.domain;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Weather at the house: three days, the temperature now and the IMGW warnings in force or announced.
* {@code pvFactor} (kWh per MJ/m² of forecast radiation) and every {@code pvForecastKwh} are null while
* there are too few measured days to calibrate. {@code stale} means part of it is an older answer
* because a source could not be reached.
*/
public record WeatherView(Instant time, Day today, Day tomorrow, Day dayAfter, Now now,
List<Warning> warnings, Double pvFactor, boolean stale) {
public record Day(LocalDate date, Integer weatherCode, String description, Double tMax, Double tMin,
Double precipitationMm, Double sunshineHours, Double pvForecastKwh) {
}
public record Now(Double temperature) {
}
/** {@code level} is IMGW's degree 1 to 3. */
public record Warning(String event, int level, OffsetDateTime from, OffsetDateTime to, String text) {
}
}
@@ -0,0 +1,672 @@
package home.backend;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import home.backend.adapters.auth.AuthSettings;
import home.backend.adapters.auth.OidcSettings;
import home.backend.adapters.auth.PasswordHash;
import home.backend.adapters.auth.StubIssuer;
import home.backend.adapters.http.ApiServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Sign-in end to end: local accounts and OIDC against a stub issuer, behind the real HTTP server. */
class AuthApiTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final String SIGNING_KEY = "0123456789abcdef0123456789abcdef-test";
private static final String PASSWORD = "correct horse";
private static final String LOGIN = "{\"username\":\"tata\",\"password\":\"" + PASSWORD + "\"}";
private static final String CALLBACK = "https://pompa.example/api/auth/oidc/callback";
private static String hash;
private final HttpClient client = HttpClient.newHttpClient();
private final List<ApiServer> servers = new ArrayList<>();
private final List<HttpServer> stubs = new ArrayList<>();
private final List<String> requestedBy = new ArrayList<>();
private StubIssuer issuer;
private ApiServer backend;
private HttpServer gateway;
@TempDir
Path dir;
@BeforeAll
static void hashOnce() {
hash = PasswordHash.create(PASSWORD.toCharArray());
}
@AfterEach
void stop() {
servers.forEach(ApiServer::stop);
stubs.forEach(s -> s.stop(0));
if (issuer != null) {
issuer.stop();
}
}
// --- set-up ---
private void launch(boolean local, boolean oidc, String role) throws IOException {
issuer = new StubIssuer();
launch(local ? users() : null, oidc ? oidcSettings(issuer.issuer(), role) : null, false);
}
private Path users() throws IOException {
return Files.writeString(dir.resolve("users.json"),
"[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]");
}
private static OidcSettings oidcSettings(URI issuer, String role) {
return new OidcSettings(issuer, StubIssuer.CLIENT_ID, StubIssuer.CLIENT_KEY, URI.create(CALLBACK), role);
}
private void launch(Path usersFile, OidcSettings oidc, boolean disabled) throws IOException {
gateway = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
gateway.createContext("/commands", this::commands);
gateway.start();
stubs.add(gateway);
URI dead = URI.create("http://127.0.0.1:1");
var settings = new Settings(URI.create("http://127.0.0.1:" + gateway.getAddress().getPort()), dead, 0,
Duration.ofSeconds(2), Duration.ofSeconds(2), "s3cret-token", null,
new AuthSettings(usersFile, oidc, SIGNING_KEY, 30, disabled, 1));
backend = Backend.assemble(settings, Clock.systemUTC());
servers.add(backend);
backend.start();
}
private void commands(HttpExchange exchange) throws IOException {
boolean post = exchange.getRequestMethod().equals("POST");
if (post) {
requestedBy.add(exchange.getRequestHeaders().getFirst("X-Requested-By"));
}
byte[] body = Files.readAllBytes(Path.of("../gateway/src/test/resources/commands/"
+ (post ? "response-applied.json" : "setpoints.json")));
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}
// --- requests ---
private HttpResponse<String> send(String method, String path, String body, String... headers) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
if (headers.length > 0) {
request.headers(headers);
}
return client.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
private HttpResponse<String> get(String path, String... headers) throws Exception {
return send("GET", path, null, headers);
}
private HttpResponse<String> post(String path, String body, String... headers) throws Exception {
var all = new ArrayList<>(List.of("Content-Type", "application/json", "X-Requested-With", "home-pump"));
all.addAll(List.of(headers));
return send("POST", path, body, all.toArray(String[]::new));
}
private static String cookie(HttpResponse<?> response, String name) {
return response.headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(name + "="))
.findFirst().map(c -> c.split(";")[0]).orElse(null);
}
private static String setCookie(HttpResponse<?> response, String name) {
return response.headers().allValues("Set-Cookie").stream().filter(c -> c.startsWith(name + "="))
.findFirst().orElse(null);
}
private String signIn() throws Exception {
return cookie(post("/api/auth/login", LOGIN), "hp_session");
}
private static JsonNode json(HttpResponse<String> response) throws IOException {
return JSON.readTree(response.body());
}
private static JsonNode example(String name) throws IOException {
return JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + name + ".json")));
}
// --- what is open and what is not ---
@Test
void configTellsWhichSignInsExist() throws Exception {
launch(true, true, null);
assertEquals(example("auth-config"), json(get("/api/auth/config")));
}
@Test
void everythingElseNeedsASession() throws Exception {
launch(true, true, null);
for (String path : List.of("/api/health", "/api/heat-pump", "/api/setpoints", "/api/audit", "/api/weather")) {
var response = get(path);
assertEquals(401, response.statusCode(), path);
assertEquals("{\"error\":\"unauthenticated\"}", response.body(), path);
}
assertEquals(401, post("/api/setpoints", "{}").statusCode());
assertEquals(401, get("/api/auth/me").statusCode());
assertEquals(200, get("/api/live").statusCode());
assertEquals("ok", get("/api/live").body());
assertEquals(404, get("/api/unknown").statusCode());
}
@Test
void sessionCookieOpensTheApiAndNamesTheRequester() throws Exception {
launch(true, false, null);
String session = signIn();
assertEquals(200, get("/api/health", "Cookie", session).statusCode());
assertEquals(200, post("/api/setpoints", "{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":2}",
"Cookie", session, "Remote-User", "intruder").statusCode());
assertEquals(List.of("tata"), requestedBy);
}
@Test
void forgedSessionCookiesAreRefused() throws Exception {
launch(true, false, null);
String session = signIn();
assertEquals(401, get("/api/health", "Cookie", session + "x").statusCode());
assertEquals(401, get("/api/health", "Cookie", "hp_session=" + session.split("\\.")[0].split("=")[1]).statusCode());
assertEquals(401, get("/api/auth/me", "Cookie", "hp_session=eyJ1c2VyIjoiYWRtaW4ifQ.AAAA").statusCode());
}
// --- local login ---
@Test
void localLoginSetsAHardenedCookieAndMeAnswers() throws Exception {
launch(true, false, null);
var login = post("/api/auth/login", LOGIN);
assertEquals(200, login.statusCode());
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"local\"}"), json(login));
String set = setCookie(login, "hp_session");
for (String flag : new String[] {"HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=2592000"}) {
assertTrue(set.contains(flag), flag);
}
var me = get("/api/auth/me", "Cookie", cookie(login, "hp_session"));
assertEquals(200, me.statusCode());
assertEquals(example("auth-me"), json(me));
}
@Test
void wrongPasswordAndUnknownUserLookAlike() throws Exception {
launch(true, false, null);
for (String body : List.of("{\"username\":\"tata\",\"password\":\"nope\"}",
"{\"username\":\"ghost\",\"password\":\"" + PASSWORD + "\"}")) {
var response = post("/api/auth/login", body);
assertEquals(401, response.statusCode());
assertEquals("{\"error\":\"invalid\"}", response.body());
assertEquals(null, setCookie(response, "hp_session"));
}
}
@Test
void fiveFailuresThrottleTheUserAndTheAddressEvenForTheRightPassword() throws Exception {
launch(true, false, null);
String wrong = "{\"username\":\"tata\",\"password\":\"nope\"}";
for (int i = 0; i < 5; i++) {
assertEquals(401, post("/api/auth/login", wrong, "X-Forwarded-For", "1.1.1." + i + ", 7.7.7.7").statusCode());
}
var user = post("/api/auth/login", LOGIN, "X-Forwarded-For", "8.8.8.8");
var address = post("/api/auth/login", "{\"username\":\"ghost\",\"password\":\"x\"}",
"X-Forwarded-For", "2.2.2.2, 7.7.7.7");
var other = post("/api/auth/login", "{\"username\":\"ghost\",\"password\":\"x\"}",
"X-Forwarded-For", "2.2.2.2, 8.8.8.8");
assertEquals(429, user.statusCode());
assertEquals("throttled", json(user).get("error").asText());
int wait = json(user).get("retryAfterSeconds").asInt();
assertTrue(wait > 14 * 60 && wait <= 15 * 60 + 1, "retry after " + wait);
assertEquals(String.valueOf(wait), user.headers().firstValue("Retry-After").orElseThrow());
assertEquals(429, address.statusCode());
assertEquals(401, other.statusCode());
}
@Test
void loginAndLogoutKeepTheWriteGuards() throws Exception {
launch(true, false, null);
assertEquals(403, post("/api/auth/login", LOGIN, "Origin", "https://evil.example").statusCode());
assertEquals(403, send("POST", "/api/auth/login", LOGIN, "Content-Type", "application/json").statusCode());
assertEquals(415, send("POST", "/api/auth/login", LOGIN, "X-Requested-With", "home-pump",
"Content-Type", "text/plain").statusCode());
assertEquals(400, post("/api/auth/login", "{}").statusCode());
assertEquals(400, post("/api/auth/login", "not json").statusCode());
assertEquals(403, send("POST", "/api/auth/logout", "{}", "Content-Type", "application/json").statusCode());
assertEquals(405, get("/api/auth/login").statusCode());
}
@Test
void sessionOfARemovedAccountStopsWorking() throws Exception {
launch(true, false, null);
String session = signIn();
assertEquals(200, get("/api/health", "Cookie", session).statusCode());
Files.writeString(dir.resolve("users.json"), "[{\"username\":\"mama\",\"passwordHash\":\"" + hash + "\"}]");
launch(dir.resolve("users.json"), null, false);
assertEquals(401, get("/api/health", "Cookie", session).statusCode());
assertEquals(401, get("/api/auth/me", "Cookie", session).statusCode());
}
@Test
void parallelWrongPasswordsAreEvaluatedAtMostFiveTimes() throws Exception {
launch(true, false, null);
var pool = java.util.concurrent.Executors.newFixedThreadPool(40);
var futures = new ArrayList<java.util.concurrent.Future<Integer>>();
for (int i = 0; i < 40; i++) {
futures.add(pool.submit(() -> post("/api/auth/login", "{\"username\":\"tata\",\"password\":\"nope\"}")
.statusCode()));
}
int evaluated = 0;
for (var future : futures) {
if (future.get() == 401) {
evaluated++;
}
}
pool.shutdown();
assertTrue(evaluated >= 1 && evaluated <= 5, "evaluated " + evaluated);
}
// --- first account and account management ---
private static final String NEW_PASSWORD = "a brand new password";
private static String account(String name, String password) {
return "{\"username\":\"" + name + "\",\"password\":\"" + password + "\"}";
}
private void launchInSetupMode() throws IOException {
launch(dir.resolve("auth-users.json"), null, false);
}
@Test
void setupCreatesTheFirstAccountOnceAndSignsItIn() throws Exception {
launchInSetupMode();
assertEquals(JSON.readTree("{\"local\":true,\"oidc\":false,\"setup\":true}"), json(get("/api/auth/config")));
var created = post("/api/auth/setup", account("tata", NEW_PASSWORD));
assertEquals(200, created.statusCode());
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"local\"}"), json(created));
assertEquals(200, get("/api/health", "Cookie", cookie(created, "hp_session")).statusCode());
assertEquals(JSON.readTree("{\"local\":true,\"oidc\":false,\"setup\":false}"), json(get("/api/auth/config")));
var second = post("/api/auth/setup", account("mama", NEW_PASSWORD));
assertEquals(409, second.statusCode());
assertEquals("{\"error\":\"setup_done\"}", second.body());
assertEquals(200, post("/api/auth/login", account("tata", NEW_PASSWORD)).statusCode());
assertEquals(401, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
}
@Test
void setupWithAWeakPasswordOrOddNameIsRefusedAndCreatesNothing() throws Exception {
launchInSetupMode();
var weak = post("/api/auth/setup", account("tata", "short"));
var odd = post("/api/auth/setup", account("a b", NEW_PASSWORD));
assertEquals(400, weak.statusCode());
assertEquals("{\"error\":\"weak_password\"}", weak.body());
assertEquals(400, odd.statusCode());
assertEquals(true, json(get("/api/auth/config")).get("setup").asBoolean());
assertFalse(Files.exists(dir.resolve("auth-users.json")));
}
@Test
void setupKeepsTheWriteGuards() throws Exception {
launchInSetupMode();
assertEquals(403, post("/api/auth/setup", account("tata", NEW_PASSWORD), "Origin", "https://evil.example")
.statusCode());
assertEquals(403, send("POST", "/api/auth/setup", account("tata", NEW_PASSWORD), "Content-Type",
"application/json").statusCode());
assertEquals(415, send("POST", "/api/auth/setup", account("tata", NEW_PASSWORD), "X-Requested-With",
"home-pump", "Content-Type", "text/plain").statusCode());
}
@Test
void setupIsNotFoundWithoutLocalAccounts() throws Exception {
launch(false, true, null);
assertEquals(404, post("/api/auth/setup", account("tata", NEW_PASSWORD)).statusCode());
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":true,\"setup\":false}"), json(get("/api/auth/config")));
}
@Test
void twoParallelSetupsLeaveExactlyOneWinner() throws Exception {
launchInSetupMode();
var pool = java.util.concurrent.Executors.newFixedThreadPool(2);
var a = pool.submit(() -> post("/api/auth/setup", account("tata", NEW_PASSWORD)).statusCode());
var b = pool.submit(() -> post("/api/auth/setup", account("mama", NEW_PASSWORD)).statusCode());
var codes = new java.util.TreeSet<>(List.of(a.get(), b.get()));
pool.shutdown();
assertEquals(new java.util.TreeSet<>(List.of(200, 409)), codes);
assertEquals(1, JSON.readTree(Files.readString(dir.resolve("auth-users.json"))).size());
}
@Test
void accountManagementNeedsASession() throws Exception {
launch(true, false, null);
assertEquals(401, get("/api/auth/users").statusCode());
assertEquals(401, post("/api/auth/users", account("mama", NEW_PASSWORD)).statusCode());
assertEquals(401, post("/api/auth/password", "{\"current\":\"x\",\"new\":\"" + NEW_PASSWORD + "\"}")
.statusCode());
assertEquals(401, post("/api/auth/users/tata/delete", "{}").statusCode());
}
@Test
void usersCanBeListedAddedAndRemovedButNotTheLastOne() throws Exception {
launch(true, false, null);
String session = signIn();
assertEquals(JSON.readTree("[{\"username\":\"tata\"}]"), json(get("/api/auth/users", "Cookie", session)));
assertEquals(200, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
assertEquals(409, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
assertEquals(400, post("/api/auth/users", account("zosia", "short"), "Cookie", session).statusCode());
assertEquals(2, json(get("/api/auth/users", "Cookie", session)).size());
assertEquals(200, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
assertEquals(204, post("/api/auth/users/mama/delete", "{}", "Cookie", session).statusCode());
assertEquals(404, post("/api/auth/users/mama/delete", "{}", "Cookie", session).statusCode());
var last = post("/api/auth/users/tata/delete", "{}", "Cookie", session);
assertEquals(409, last.statusCode());
assertEquals("{\"error\":\"last_user\"}", last.body());
assertEquals(403, send("POST", "/api/auth/users/mama/delete", "{}", "Cookie", session,
"Content-Type", "application/json").statusCode());
}
@Test
void anOidcSessionMayManageAccountsButNotChangeAPassword() throws Exception {
launch(true, true, null);
Started started = start();
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
String session = cookie(callbackWithCode(started), "hp_session");
assertEquals(200, post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session).statusCode());
assertEquals(403, post("/api/auth/password", "{\"current\":\"x\",\"new\":\"" + NEW_PASSWORD + "\"}",
"Cookie", session).statusCode());
}
@Test
void removedAccountLosesItsSessionAtOnceAndRemovingYourselfSignsYouOut() throws Exception {
launch(true, false, null);
String tata = signIn();
post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", tata);
String mama = cookie(post("/api/auth/login", account("mama", NEW_PASSWORD)), "hp_session");
assertEquals(200, get("/api/health", "Cookie", mama).statusCode());
assertEquals(204, post("/api/auth/users/mama/delete", "{}", "Cookie", tata).statusCode());
assertEquals(401, get("/api/health", "Cookie", mama).statusCode());
post("/api/auth/users", account("zosia", NEW_PASSWORD), "Cookie", tata);
var self = post("/api/auth/users/tata/delete", "{}", "Cookie", tata);
assertEquals(204, self.statusCode());
assertTrue(setCookie(self, "hp_session").startsWith("hp_session=; Max-Age=0"));
assertEquals(401, get("/api/health", "Cookie", tata).statusCode());
}
@Test
void ownPasswordChangeNeedsTheCurrentOneAndCountsFailuresInTheThrottle() throws Exception {
launch(true, false, null);
String session = signIn();
String wrong = "{\"current\":\"nope\",\"new\":\"" + NEW_PASSWORD + "\"}";
assertEquals(400, post("/api/auth/password", "{\"current\":\"" + PASSWORD + "\",\"new\":\"short\"}",
"Cookie", session).statusCode());
var rejected = post("/api/auth/password", wrong, "Cookie", session);
assertEquals(401, rejected.statusCode());
assertEquals("{\"error\":\"invalid\"}", rejected.body());
assertEquals(204, post("/api/auth/password", "{\"current\":\"" + PASSWORD + "\",\"new\":\""
+ NEW_PASSWORD + "\"}", "Cookie", session).statusCode());
assertEquals(401, post("/api/auth/login", LOGIN).statusCode());
assertEquals(200, post("/api/auth/login", account("tata", NEW_PASSWORD)).statusCode());
for (int i = 0; i < 5; i++) {
post("/api/auth/password", wrong, "Cookie", session);
}
assertEquals(429, post("/api/auth/password", wrong, "Cookie", session).statusCode());
}
@Test
void changesSurviveARestartBecauseTheFileIsRewritten() throws Exception {
launch(true, false, null);
String session = signIn();
post("/api/auth/users", account("mama", NEW_PASSWORD), "Cookie", session);
launch(dir.resolve("users.json"), null, false);
assertEquals(200, post("/api/auth/login", account("mama", NEW_PASSWORD)).statusCode());
}
@Test
void logoutClearsTheCookie() throws Exception {
launch(true, false, null);
var response = post("/api/auth/logout", "{}", "Cookie", signIn());
assertEquals(204, response.statusCode());
assertTrue(setCookie(response, "hp_session").startsWith("hp_session=; Max-Age=0"));
}
@Test
void loginIsNotFoundWithoutLocalAccounts() throws Exception {
launch(false, true, null);
assertEquals(404, post("/api/auth/login", LOGIN).statusCode());
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":true,\"setup\":false}"), json(get("/api/auth/config")));
}
@Test
void disabledAuthenticationOpensTheApiAsPhone() throws Exception {
launch(null, null, true);
assertEquals(200, get("/api/health").statusCode());
assertEquals(JSON.readTree("{\"user\":\"phone\",\"method\":\"none\"}"), json(get("/api/auth/me")));
assertEquals(JSON.readTree("{\"local\":false,\"oidc\":false,\"setup\":false}"), json(get("/api/auth/config")));
assertEquals(204, post("/api/auth/logout", "{}").statusCode());
}
// --- OIDC ---
private record Started(Map<String, String> query, String transaction) {
}
private Started start() throws Exception {
var response = get("/api/auth/oidc/start");
assertEquals(302, response.statusCode());
URI location = URI.create(response.headers().firstValue("Location").orElseThrow());
assertEquals(issuer.issuer() + "/protocol/openid-connect/auth", location.toString().split("\\?")[0]);
String set = setCookie(response, "hp_oidc");
assertTrue(set.contains("HttpOnly") && set.contains("Secure") && set.contains("SameSite=Lax")
&& set.contains("Max-Age=600"), set);
return new Started(query(location.getRawQuery()), cookie(response, "hp_oidc"));
}
private static Map<String, String> query(String raw) {
Map<String, String> query = new HashMap<>();
for (String pair : raw.split("&")) {
String[] kv = pair.split("=", 2);
query.put(kv[0], URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
}
return query;
}
private HttpResponse<String> callback(Started started, String queryString) throws Exception {
return get("/api/auth/oidc/callback?" + queryString, "Cookie", started.transaction());
}
private HttpResponse<String> callbackWithCode(Started started) throws Exception {
return callback(started, "code=abc&state=" + started.query().get("state"));
}
@Test
void oidcLoginSendsTheRightRequestAndSignsTheUserIn() throws Exception {
launch(false, true, "home-pump");
Started started = start();
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
var response = callbackWithCode(started);
assertEquals(302, response.statusCode());
assertEquals("/", response.headers().firstValue("Location").orElseThrow());
assertTrue(setCookie(response, "hp_oidc").startsWith("hp_oidc=; Max-Age=0"));
var me = get("/api/auth/me", "Cookie", cookie(response, "hp_session"));
assertEquals(JSON.readTree("{\"user\":\"tata\",\"method\":\"oidc\"}"), json(me));
var authorize = started.query();
assertEquals("code", authorize.get("response_type"));
assertEquals(StubIssuer.CLIENT_ID, authorize.get("client_id"));
assertEquals(CALLBACK, authorize.get("redirect_uri"));
assertEquals("S256", authorize.get("code_challenge_method"));
assertTrue(authorize.get("scope").contains("openid"));
var token = issuer.tokenRequests.getFirst();
assertEquals("Basic " + Base64.getEncoder().encodeToString(
(StubIssuer.CLIENT_ID + ":" + StubIssuer.CLIENT_KEY).getBytes(StandardCharsets.UTF_8)),
token.authorization());
assertEquals("authorization_code", token.form().get("grant_type"));
assertEquals("abc", token.form().get("code"));
assertEquals(CALLBACK, token.form().get("redirect_uri"));
assertEquals(authorize.get("code_challenge"), Base64.getUrlEncoder().withoutPadding().encodeToString(
MessageDigest.getInstance("SHA-256").digest(
token.form().get("code_verifier").getBytes(StandardCharsets.US_ASCII))));
}
@Test
void oidcTokensThatDoNotVerifyEndInOidcFailed() throws Exception {
launch(false, true, null);
long now = Instant.now().getEpochSecond();
List<Function<String, String>> bad = List.of(
nonce -> StubIssuer.sign("RS256", "k1", Map.of("iss", issuer.issuer().toString(),
"aud", StubIssuer.CLIENT_ID, "nonce", nonce, "iat", now, "exp", now + 300,
"preferred_username", "tata"), StubIssuer.newKeys().getPrivate()),
nonce -> issuer.idToken(nonce, c -> c.put("aud", "other")),
nonce -> issuer.idToken(nonce, c -> c.put("exp", now - 600)),
nonce -> issuer.idToken("another-nonce", c -> { }));
for (var token : bad) {
Started started = start();
issuer.idTokenToServe = token.apply(started.query().get("nonce"));
var response = callbackWithCode(started);
assertEquals("/#login?error=oidc_failed", response.headers().firstValue("Location").orElseThrow());
assertEquals(null, cookie(response, "hp_session"));
}
}
@Test
void wrongStateOrMissingLoginIsRefusedWithoutAskingTheIssuer() throws Exception {
launch(false, true, null);
Started started = start();
var wrongState = callback(started, "code=abc&state=forged");
var noCookie = get("/api/auth/oidc/callback?code=abc&state=" + started.query().get("state"));
var noParameters = callback(started, "");
for (var response : List.of(wrongState, noCookie, noParameters)) {
assertEquals("/#login?error=oidc_failed", response.headers().firstValue("Location").orElseThrow());
}
assertTrue(issuer.tokenRequests.isEmpty());
}
@Test
void missingRoleIsDenied() throws Exception {
launch(false, true, "admin");
Started started = start();
issuer.idTokenToServe = issuer.idToken(started.query().get("nonce"), c -> { });
var response = callbackWithCode(started);
assertEquals("/#login?error=oidc_denied", response.headers().firstValue("Location").orElseThrow());
assertEquals(null, cookie(response, "hp_session"));
}
@Test
void refusedByTheUserIsDenied() throws Exception {
launch(false, true, null);
Started started = start();
var response = callback(started, "error=access_denied&state=" + started.query().get("state"));
assertEquals("/#login?error=oidc_denied", response.headers().firstValue("Location").orElseThrow());
}
@Test
void tokenEndpointRefusalIsOidcFailed() throws Exception {
launch(false, true, null);
Started started = start();
issuer.tokenStatus = 400;
assertEquals("/#login?error=oidc_failed",
callbackWithCode(started).headers().firstValue("Location").orElseThrow());
}
@Test
void keycloakDownBeforeTheStartRedirectsBackWhileLocalLoginWorks() throws Exception {
issuer = new StubIssuer();
URI gone = issuer.issuer();
issuer.stop();
launch(users(), oidcSettings(gone, null), false);
var start = get("/api/auth/oidc/start");
assertEquals(302, start.statusCode());
assertEquals("/#login?error=oidc_unavailable", start.headers().firstValue("Location").orElseThrow());
assertEquals(null, cookie(start, "hp_oidc"));
assertEquals(200, get("/api/auth/config").statusCode());
assertTrue(signIn() != null);
}
@Test
void keycloakDownDuringTheCallbackIsUnavailable() throws Exception {
launch(true, true, null);
Started started = start();
issuer.stop();
var response = callbackWithCode(started);
assertEquals("/#login?error=oidc_unavailable", response.headers().firstValue("Location").orElseThrow());
assertEquals(200, post("/api/auth/login", LOGIN).statusCode());
}
}
@@ -0,0 +1,96 @@
package home.backend;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The environment rules that keep the backend from starting open by accident. */
class AuthSettingsTest {
private static final String SIGNING_KEY = "0123456789abcdef0123456789abcdef";
private static Map<String, String> env(String... extra) {
Map<String, String> env = new HashMap<>(Map.of("GATEWAY_URL", "http://10.0.0.2:8080",
"INFLUX_URL", "https://influx.example"));
for (int i = 0; i < extra.length; i += 2) {
env.put(extra[i], extra[i + 1]);
}
return env;
}
private static String refusal(String... extra) {
return assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(env(extra))).getMessage();
}
@Test
void withoutAnySignInTheBackendRefusesToStart() {
assertTrue(refusal().contains("AUTH_DISABLED=true"));
assertTrue(refusal("AUTH_DISABLED", "false").contains("no sign-in configured"));
}
@Test
void authDisabledIsAnExplicitChoice() {
var auth = Settings.fromEnvironment(env("AUTH_DISABLED", "true")).auth();
assertTrue(auth.disabled());
assertNull(auth.oidc());
}
@Test
void disabledContradictsConfiguredSignIn() {
assertTrue(refusal("AUTH_DISABLED", "true", "USERS_FILE", "/data/users.json").contains("contradicts"));
}
@Test
void configuredSignInNeedsALongSessionSecret() {
assertTrue(refusal("USERS_FILE", "/data/users.json").contains("SESSION_SECRET"));
assertTrue(refusal("USERS_FILE", "/data/users.json", "SESSION_SECRET", "short").contains("32 bytes"));
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "OIDC_CLIENT_ID", "x",
"OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb").contains("SESSION_SECRET"));
}
@Test
void oidcWithoutARequiredRoleWarns() {
var open = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
"OIDC_CLIENT_ID", "x", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb",
"SESSION_SECRET", SIGNING_KEY)).auth();
var restricted = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
"OIDC_CLIENT_ID", "x", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI", "https://pompa.example/cb",
"OIDC_REQUIRED_ROLE", "family", "SESSION_SECRET", SIGNING_KEY)).auth();
assertTrue(open.warnings().getFirst().contains("every user of the Keycloak realm"));
assertTrue(restricted.warnings().isEmpty());
}
@Test
void oidcNeedsItsClientSettings() {
assertTrue(refusal("OIDC_ISSUER", "https://login.example/realms/home", "SESSION_SECRET", SIGNING_KEY)
.contains("OIDC_CLIENT_ID"));
}
@Test
void localAndOidcAreConfiguredIndependently() {
var local = Settings.fromEnvironment(env("USERS_FILE", "/data/users.json", "SESSION_SECRET", SIGNING_KEY)).auth();
var oidc = Settings.fromEnvironment(env("OIDC_ISSUER", "https://login.example/realms/home",
"OIDC_CLIENT_ID", "home-pump", "OIDC_CLIENT_SECRET", "y", "OIDC_REDIRECT_URI",
"https://pompa.example/api/auth/oidc/callback", "OIDC_REQUIRED_ROLE", "family",
"SESSION_SECRET", SIGNING_KEY, "SESSION_DAYS", "7", "TRUSTED_PROXY_HOPS", "2")).auth();
assertEquals("/data/users.json", local.usersFile().toString());
assertNull(local.oidc());
assertEquals(30, local.sessionDays());
assertEquals(1, local.proxyHops());
assertNull(oidc.usersFile());
assertEquals("family", oidc.oidc().requiredRole());
assertEquals(7, oidc.sessionDays());
assertEquals(2, oidc.proxyHops());
assertFalse(oidc.disabled());
}
}
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import home.backend.adapters.auth.AuthSettings;
import home.backend.adapters.http.ApiServer; import home.backend.adapters.http.ApiServer;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -43,6 +44,8 @@ class BackendTest {
/** Just after the readings in the snapshot fixture, so they count as fresh. */ /** Just after the readings in the snapshot fixture, so they count as fresh. */
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC); private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC);
private static final AuthSettings OPEN = new AuthSettings(null, null, null, 30, true, 1);
private final HttpClient client = HttpClient.newHttpClient(); private final HttpClient client = HttpClient.newHttpClient();
private HttpServer gateway; private HttpServer gateway;
private HttpServer influx; private HttpServer influx;
@@ -56,7 +59,7 @@ class BackendTest {
gateway.createContext("/commands", this::commands); gateway.createContext("/commands", this::commands);
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json")); influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2), var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
"s3cret-token", null); "s3cret-token", null, OPEN);
backend = Backend.assemble(settings, CLOCK); backend = Backend.assemble(settings, CLOCK);
backend.start(); backend.start();
} }
@@ -83,7 +86,7 @@ class BackendTest {
private HttpResponse<String> post(String path, String body) throws Exception { private HttpResponse<String> post(String path, String body) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path)) var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
.header("Remote-User", "anna").header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("X-Requested-With", "home-pump") .header("X-Requested-With", "home-pump")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(); .POST(HttpRequest.BodyPublishers.ofString(body)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
@@ -151,6 +154,11 @@ class BackendTest {
assertServesExactly("/api/heat-pump", "heat-pump"); assertServesExactly("/api/heat-pump", "heat-pump");
} }
@Test
void servesPiSensorFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/pi-sensor", "pi-sensor");
}
@Test @Test
void servesEnergyFromTheGatewayReadings() throws Exception { void servesEnergyFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/energy", "energy"); assertServesExactly("/api/energy", "energy");
@@ -194,7 +202,7 @@ class BackendTest {
JSON.readTree(response.body())); JSON.readTree(response.body()));
assertEquals(1, commandsSeen.size()); assertEquals(1, commandsSeen.size());
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization()); assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
assertEquals("anna", commandsSeen.getFirst().requestedBy()); assertEquals("phone", commandsSeen.getFirst().requestedBy());
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))), assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
JSON.readTree(commandsSeen.getFirst().body())); JSON.readTree(commandsSeen.getFirst().body()));
} }
@@ -204,7 +212,7 @@ class BackendTest {
Path file = Files.writeString(dir.resolve("presets.json"), Path file = Files.writeString(dir.resolve("presets.json"),
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]"); "[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
Duration.ofSeconds(2), "s3cret-token", file), CLOCK); Duration.ofSeconds(2), "s3cret-token", file, OPEN), CLOCK);
custom.start(); custom.start();
try { try {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath")) var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
@@ -224,7 +232,7 @@ class BackendTest {
@Test @Test
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception { void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
Duration.ofSeconds(2), null, null), CLOCK); Duration.ofSeconds(2), null, null, OPEN), CLOCK);
noToken.start(); noToken.start();
try { try {
var base = "http://127.0.0.1:" + noToken.port(); var base = "http://127.0.0.1:" + noToken.port();
@@ -251,7 +259,7 @@ class BackendTest {
@Test @Test
void urlsMayEndWithASingleSlash() { void urlsMayEndWithASingleSlash() {
var settings = Settings.fromEnvironment( var settings = Settings.fromEnvironment(
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/")); Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/", "AUTH_DISABLED", "true"));
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl()); assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
} }
@@ -259,7 +267,7 @@ class BackendTest {
@Test @Test
void settingsHaveDefaults() { void settingsHaveDefaults() {
var settings = Settings.fromEnvironment( var settings = Settings.fromEnvironment(
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example")); Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example", "AUTH_DISABLED", "true"));
assertEquals(8080, settings.port()); assertEquals(8080, settings.port());
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout()); assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
@@ -268,7 +276,8 @@ class BackendTest {
@Test @Test
void tokenAndPresetsFileAreOptional() { void tokenAndPresetsFileAreOptional() {
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example"); var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example",
"AUTH_DISABLED", "true");
var none = Settings.fromEnvironment(urls); var none = Settings.fromEnvironment(urls);
var env = new HashMap<>(urls); var env = new HashMap<>(urls);
env.put("GATEWAY_TOKEN", " s3cret-token "); env.put("GATEWAY_TOKEN", " s3cret-token ");
@@ -0,0 +1,151 @@
package home.backend.adapters.auth;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URI;
import java.time.Clock;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class IdTokenVerifierTest {
private static StubIssuer issuer;
@BeforeAll
static void start() throws IOException {
issuer = new StubIssuer();
}
@AfterAll
static void stop() {
issuer.stop();
}
private IdTokenVerifier verifier(String role) {
return new IdTokenVerifier(new OidcSettings(issuer.issuer(), StubIssuer.CLIENT_ID, "x",
URI.create("https://pompa.example/api/auth/oidc/callback"), role), Clock.systemUTC());
}
private String verify(String token, String role) throws OidcException {
return verifier(role).verify(token, "n1", kid -> kid.equals("k1") ? issuer.publicKey() : null);
}
private OidcException failure(String token, String role) {
return assertThrows(OidcException.class, () -> verify(token, role));
}
private String token(Consumer<Map<String, Object>> change) {
return issuer.idToken("n1", change);
}
@Test
void validTokenGivesTheUserName() throws Exception {
assertEquals("tata", verify(token(c -> { }), "home-pump"));
}
@Test
void userNameFallsBackToEmailThenSubject() throws Exception {
assertEquals("tata@example.pl", verify(token(c -> {
c.remove("preferred_username");
c.put("email", "tata@example.pl");
}), null));
assertEquals("7f1c", verify(token(c -> c.remove("preferred_username")), null));
}
@Test
void signatureOfAnotherKeyIsRefused() {
var forged = StubIssuer.sign("RS256", "k1", Map.of("iss", issuer.issuer().toString(), "aud", "home-pump"),
StubIssuer.newKeys().getPrivate());
assertEquals(FAILED, failure(forged, null).reason());
}
@Test
void tamperedPayloadIsRefused() {
String[] parts = token(c -> { }).split("\\.");
String other = token(c -> c.put("preferred_username", "admin")).split("\\.")[1];
assertEquals(FAILED, failure(parts[0] + "." + other + "." + parts[2], null).reason());
}
@Test
void otherAlgorithmsAreRefused() {
String none = "eyJhbGciOiJub25lIn0." + token(c -> { }).split("\\.")[1] + ".";
assertEquals(FAILED, failure(none, null).reason());
}
@Test
void tokenForAnotherClientIsRefused() {
assertEquals(FAILED, failure(token(c -> c.put("aud", "other-app")), null).reason());
}
@Test
void severalAudiencesNeedOurClientAsAuthorizedParty() throws Exception {
assertEquals(FAILED, failure(token(c -> c.put("aud", List.of("home-pump", "account"))), null).reason());
assertEquals("tata", verify(token(c -> {
c.put("aud", List.of("home-pump", "account"));
c.put("azp", "home-pump");
}), null));
}
@Test
void wrongIssuerIsRefused() {
assertEquals(FAILED, failure(token(c -> c.put("iss", "https://evil.example/realms/home")), null).reason());
}
@Test
void expiredTokenIsRefusedButASmallSkewIsAllowed() throws Exception {
long now = java.time.Instant.now().getEpochSecond();
assertEquals(FAILED, failure(token(c -> c.put("exp", now - 120)), null).reason());
assertEquals("tata", verify(token(c -> c.put("exp", now - 10)), null));
}
@Test
void tokenIssuedInTheFutureIsRefused() {
long now = java.time.Instant.now().getEpochSecond();
assertEquals(FAILED, failure(token(c -> c.put("iat", now + 600)), null).reason());
}
@Test
void wrongNonceIsRefused() {
assertEquals(FAILED, failure(issuer.idToken("another", c -> { }), null).reason());
}
@Test
void requiredRoleMayBeARealmRoleOrAClientRole() throws Exception {
assertEquals("tata", verify(token(c -> { }), "home-pump"));
assertEquals("tata", verify(token(c -> {
c.remove("realm_access");
c.put("resource_access", Map.of("home-pump", Map.of("roles", List.of("family"))));
}), "family"));
}
@Test
void missingRoleIsDenied() {
assertEquals(DENIED, failure(token(c -> { }), "admin").reason());
assertEquals(DENIED, failure(token(c -> c.put("resource_access",
Map.of("account", Map.of("roles", List.of("admin"))))), "admin").reason());
}
@Test
void unknownKeyIsRefused() {
var verifier = verifier(null);
assertEquals(FAILED, assertThrows(OidcException.class,
() -> verifier.verify(token(c -> { }), "n1", kid -> {
throw new OidcException(FAILED, "no signing key " + kid);
})).reason());
}
}
@@ -0,0 +1,164 @@
package home.backend.adapters.auth;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Hashing, the accounts file and the hash-password command. */
class PasswordTest {
private static String hash;
@BeforeAll
static void hashOnce() {
hash = PasswordHash.create("correct horse".toCharArray());
}
@Test
void hashIsSelfDescribingSaltedAndChecked() {
String[] parts = hash.split("\\$");
assertEquals("pbkdf2-sha256", parts[0]);
assertTrue(Integer.parseInt(parts[1]) >= 310_000);
assertTrue(PasswordHash.matches(hash, "correct horse".toCharArray()));
assertFalse(PasswordHash.matches(hash, "wrong horse".toCharArray()));
assertNotEquals(hash, PasswordHash.create("correct horse".toCharArray()));
}
@Test
void weakOrMalformedHashesAreRefused() {
for (String bad : new String[] {"", "plain", "pbkdf2-sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA",
"pbkdf2-sha256$x$y$z", "md5$600000$AAAAAAAAAAAAAAAAAAAAAA==$AAAA"}) {
assertThrows(IllegalArgumentException.class, () -> PasswordHash.requireValid(bad), bad);
}
}
@Test
void usersFileHoldsAccounts(@TempDir Path dir) throws IOException {
UserStore users = UserStore.load(file(dir, "[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]"));
assertTrue(users.authenticate("tata", "correct horse".toCharArray()));
assertFalse(users.authenticate("tata", "nope".toCharArray()));
assertFalse(users.authenticate("ghost", "correct horse".toCharArray()));
}
@Test
void badUsersFilesStopTheStart(@TempDir Path dir) throws IOException {
String account = "{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}";
for (String bad : new String[] {"not json", "[" + account + "," + account + "]",
"[{\"username\":\"a b\",\"passwordHash\":\"" + hash + "\"}]",
"[{\"username\":\"tata\",\"passwordHash\":\"plain\"}]"}) {
Path file = file(dir, bad);
assertThrows(IllegalArgumentException.class, () -> UserStore.load(file), bad);
}
}
@Test
void missingOrEmptyFileIsAnEmptyStoreForSetup(@TempDir Path dir) throws IOException {
assertTrue(UserStore.load(dir.resolve("missing.json")).isEmpty());
assertTrue(UserStore.load(file(dir, "[]")).isEmpty());
}
@Test
void changesAreWrittenAtomicallyPrivatelyAndReadBack(@TempDir Path dir) throws IOException {
Path file = dir.resolve("users.json");
UserStore users = UserStore.load(file);
assertTrue(users.addFirst("tata", hash));
assertFalse(users.addFirst("mama", hash));
assertTrue(users.add("mama", hash));
assertFalse(users.add("mama", hash));
assertTrue(users.changePassword("mama", PasswordHash.create("another password".toCharArray())));
assertFalse(users.changePassword("ghost", hash));
assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file)));
try (var listing = Files.list(dir)) {
assertEquals(List.of("users.json"), listing.map(p -> p.getFileName().toString()).toList());
}
UserStore again = UserStore.load(file);
assertEquals(List.of("tata", "mama"), again.usernames());
assertTrue(again.authenticate("tata", "correct horse".toCharArray()));
assertTrue(again.authenticate("mama", "another password".toCharArray()));
}
@Test
void lastAccountCannotBeRemoved(@TempDir Path dir) {
UserStore users = UserStore.load(dir.resolve("users.json"));
users.addFirst("tata", hash);
users.add("mama", hash);
assertEquals(UserStore.Removal.REMOVED, users.remove("mama"));
assertEquals(UserStore.Removal.LAST, users.remove("tata"));
assertEquals(UserStore.Removal.UNKNOWN, users.remove("mama"));
assertEquals(List.of("tata"), UserStore.load(dir.resolve("users.json")).usernames());
}
@Test
void failedWriteLeavesTheStoreAsItWas(@TempDir Path dir) {
UserStore users = UserStore.load(dir.resolve("gone/users.json"));
assertThrows(UncheckedIOException.class, () -> users.addFirst("tata", hash));
assertTrue(users.isEmpty());
}
/** An unknown user is run through the same hashing, so the answer does not tell which names exist. */
@Test
void unknownUserCostsAboutAsMuchAsAWrongPassword(@TempDir Path dir) throws IOException {
UserStore users = UserStore.load(file(dir, "[{\"username\":\"tata\",\"passwordHash\":\"" + hash + "\"}]"));
users.authenticate("tata", "warm up".toCharArray());
long wrong = nanos(() -> users.authenticate("tata", "nope".toCharArray()));
long unknown = nanos(() -> users.authenticate("ghost", "nope".toCharArray()));
assertTrue(unknown > wrong / 2, "unknown " + unknown + " ns vs wrong " + wrong + " ns");
}
@Test
void commandPrintsAHashFromStdin() throws IOException {
var out = new ByteArrayOutputStream();
int code = PasswordHashCli.run(null, stream("s3cr3t-pass\n"), new PrintStream(out), System.err);
assertEquals(0, code);
assertTrue(PasswordHash.matches(out.toString(StandardCharsets.UTF_8).trim(), "s3cr3t-pass".toCharArray()));
}
@Test
void commandRefusesAnEmptyPassword() throws IOException {
var err = new ByteArrayOutputStream();
assertEquals(1, PasswordHashCli.run(null, stream("\n"), System.out, new PrintStream(err)));
assertTrue(err.toString(StandardCharsets.UTF_8).contains("No password"));
}
private static ByteArrayInputStream stream(String text) {
return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8));
}
private static Path file(Path dir, String content) throws IOException {
return Files.writeString(dir.resolve("users-" + content.hashCode() + ".json"), content);
}
private static long nanos(Runnable action) {
long start = System.nanoTime();
action.run();
return System.nanoTime() - start;
}
}
@@ -0,0 +1,153 @@
package home.backend.adapters.auth;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The session cookie, the throttle and the other signed cookie. */
class SessionsTest {
private static final byte[] SIGNING_KEY = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
private static final Instant T0 = Instant.parse("2026-10-24T10:00:00Z");
private Instant now = T0;
private final Clock clock = new Clock() {
@Override
public ZoneOffset getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(java.time.ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return now;
}
};
private final SignedTokens tokens = new SignedTokens(SIGNING_KEY, clock);
private final Sessions sessions = new Sessions(tokens, clock, Duration.ofDays(30));
private static String cookieHeader(String setCookie) {
return setCookie.split(";")[0];
}
@Test
void cookieCarriesTheSessionAndTheRightFlags() {
String setCookie = sessions.setCookie("tata", "local");
assertEquals(Optional.of(new Session("tata", "local", T0)), sessions.read(cookieHeader(setCookie)));
for (String flag : new String[] {"HttpOnly", "Secure", "SameSite=Lax", "Path=/", "Max-Age=2592000"}) {
assertTrue(setCookie.contains(flag), flag);
}
}
@Test
void sessionExpiresAfterItsLifetime() {
String cookie = cookieHeader(sessions.setCookie("tata", "oidc"));
now = T0.plus(Duration.ofDays(30)).minusSeconds(1);
assertTrue(sessions.read(cookie).isPresent());
now = T0.plus(Duration.ofDays(30));
assertTrue(sessions.read(cookie).isEmpty());
}
@Test
void tamperedOrForeignCookiesAreRefused() {
String cookie = cookieHeader(sessions.setCookie("tata", "local"));
String other = cookieHeader(sessions.setCookie("mama", "local"));
String forgedPayload = other.substring(0, other.indexOf('.')) + cookie.substring(cookie.indexOf('.'));
var otherSecret = new Sessions(new SignedTokens("another-secret-another-secret-12".getBytes(StandardCharsets.UTF_8),
clock), clock, Duration.ofDays(30));
assertTrue(sessions.read(forgedPayload).isEmpty());
assertTrue(sessions.read(cookie + "x").isEmpty());
assertTrue(sessions.read("hp_session=garbage").isEmpty());
assertTrue(sessions.read("hp_session=a.b.c").isEmpty());
assertTrue(sessions.read(null).isEmpty());
assertTrue(otherSecret.read(cookie).isEmpty());
}
@Test
void aTokenForAnotherPurposeIsNotASession() {
String token = tokens.issue("oidc-login", Duration.ofMinutes(10),
Map.of("user", "tata", "method", "local", "iat", "0"));
assertTrue(sessions.read("hp_session=" + token).isEmpty());
}
@Test
void clearingExpiresTheCookie() {
assertTrue(sessions.clearCookie().startsWith("hp_session=; Max-Age=0"));
}
@Test
void throttleBlocksTheSixthAttemptUntilTheOldestLeavesTheWindow() {
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
for (int i = 0; i < 4; i++) {
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
}
now = T0.plusSeconds(60);
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
assertEquals(15 * 60 - 60 + 1, throttle.tryAcquire("user:tata"));
assertEquals(0, throttle.tryAcquire("user:mama", "ip:5.6.7.8"));
now = T0.plus(Duration.ofMinutes(15));
assertEquals(0, throttle.tryAcquire("user:tata", "ip:1.2.3.4"));
}
@Test
void releasedAttemptsAndSuccessDoNotCount() {
var throttle = new LoginThrottle(clock, 2, Duration.ofMinutes(15));
throttle.tryAcquire("user:tata");
throttle.release("user:tata");
throttle.tryAcquire("user:tata");
assertEquals(0, throttle.tryAcquire("user:tata"));
assertTrue(throttle.tryAcquire("user:tata") > 0);
throttle.cleared("user:tata");
assertEquals(0, throttle.tryAcquire("user:tata"));
}
@Test
void parallelAttemptsCannotOutrunTheLimit() throws Exception {
var throttle = new LoginThrottle(clock, 5, Duration.ofMinutes(15));
var pool = java.util.concurrent.Executors.newFixedThreadPool(32);
var allowed = new java.util.concurrent.atomic.AtomicInteger();
var tasks = new java.util.ArrayList<java.util.concurrent.Future<?>>();
for (int i = 0; i < 200; i++) {
tasks.add(pool.submit(() -> {
if (throttle.tryAcquire("user:tata", "ip:1.2.3.4") == 0) {
allowed.incrementAndGet();
}
}));
}
for (var task : tasks) {
task.get();
}
pool.shutdown();
assertEquals(5, allowed.get());
}
@Test
void odditiesInTheProvidersErrorAreCleanedForTheLog() {
assertEquals("server_error", OidcFlow.printable("server_error"));
assertEquals("a?b?c", OidcFlow.printable("a\nb\rc"));
assertEquals(40 + 3, OidcFlow.printable("x".repeat(100)).length());
assertEquals("nothing", OidcFlow.printable(null));
}
}
@@ -0,0 +1,140 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.interfaces.RSAPublicKey;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/** A Keycloak stand-in on localhost: discovery, signing keys and a token endpoint that hands out what a test says. */
public final class StubIssuer {
public static final String CLIENT_ID = "home-pump";
public static final String CLIENT_KEY = "stub-client-secret";
private static final ObjectMapper JSON = new ObjectMapper();
/** What the token endpoint received. */
public record TokenRequest(String authorization, Map<String, String> form) {
}
public final List<TokenRequest> tokenRequests = new ArrayList<>();
/** What the token endpoint answers with; set by the test. */
public volatile String idTokenToServe = "";
public volatile int tokenStatus = 200;
private final KeyPair keys = newKeys();
private final HttpServer server;
public StubIssuer() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/realms/home/.well-known/openid-configuration", exchange -> reply(exchange, 200,
Map.of("issuer", issuer().toString(),
"authorization_endpoint", issuer() + "/protocol/openid-connect/auth",
"token_endpoint", issuer() + "/protocol/openid-connect/token",
"jwks_uri", issuer() + "/protocol/openid-connect/certs")));
server.createContext("/realms/home/protocol/openid-connect/certs", exchange -> reply(exchange, 200,
Map.of("keys", List.of(Map.of("kty", "RSA", "kid", "broken", "n", "***", "e", "AQAB"), jwk()))));
server.createContext("/realms/home/protocol/openid-connect/token", this::token);
server.start();
}
public URI issuer() {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/realms/home");
}
public java.security.PublicKey publicKey() {
return keys.getPublic();
}
public void stop() {
server.stop(0);
}
/** A valid ID token for the nonce, with {@code change} applied to the claims. */
public String idToken(String nonce, Consumer<Map<String, Object>> change) {
long now = Instant.now().getEpochSecond();
Map<String, Object> claims = new LinkedHashMap<>();
claims.put("iss", issuer().toString());
claims.put("aud", CLIENT_ID);
claims.put("sub", "7f1c");
claims.put("preferred_username", "tata");
claims.put("iat", now);
claims.put("exp", now + 300);
claims.put("nonce", nonce);
claims.put("realm_access", Map.of("roles", List.of("home-pump")));
change.accept(claims);
return sign("RS256", "k1", claims, keys.getPrivate());
}
/** Signs with any key, so a test can forge a token. */
public static String sign(String alg, String kid, Map<String, Object> claims, PrivateKey key) {
try {
String header = Base64Url.encode(JSON.writeValueAsBytes(Map.of("alg", alg, "kid", kid, "typ", "JWT")));
String signed = header + "." + Base64Url.encode(JSON.writeValueAsBytes(claims));
Signature rsa = Signature.getInstance("SHA256withRSA");
rsa.initSign(key);
rsa.update(signed.getBytes(StandardCharsets.US_ASCII));
return signed + "." + Base64Url.encode(rsa.sign());
} catch (IOException | GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
public static KeyPair newKeys() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
private Map<String, String> jwk() {
var key = (RSAPublicKey) keys.getPublic();
return Map.of("kty", "RSA", "use", "sig", "alg", "RS256", "kid", "k1",
"n", Base64Url.encode(unsigned(key.getModulus())), "e", Base64Url.encode(unsigned(key.getPublicExponent())));
}
private static byte[] unsigned(BigInteger number) {
byte[] bytes = number.toByteArray();
return bytes[0] == 0 ? java.util.Arrays.copyOfRange(bytes, 1, bytes.length) : bytes;
}
private void token(HttpExchange exchange) throws IOException {
Map<String, String> form = new HashMap<>();
for (String pair : new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8).split("&")) {
String[] kv = pair.split("=", 2);
form.put(URLDecoder.decode(kv[0], StandardCharsets.UTF_8), URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
}
tokenRequests.add(new TokenRequest(exchange.getRequestHeaders().getFirst("Authorization"), form));
reply(exchange, tokenStatus, Map.of("id_token", idTokenToServe, "token_type", "Bearer"));
}
private static void reply(HttpExchange exchange, int status, Object body) throws IOException {
byte[] bytes = JSON.writeValueAsBytes(body);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
}
@@ -2,9 +2,14 @@ package home.backend.adapters.gateway;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.Command; import home.backend.domain.Command;
import home.backend.domain.CommandResult.Status; import home.backend.domain.CommandResult.Status;
import home.backend.domain.RefreshResult;
import home.backend.domain.RestoreResult.Outcome; import home.backend.domain.RestoreResult.Outcome;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -19,6 +24,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -60,16 +66,20 @@ class GatewayHttpCommandsTest {
} }
private void answerCommands() { private void answerCommands() {
server.createContext("/commands", exchange -> { for (String path : List.of("/commands", "/battery", "/refresh")) {
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(), server.createContext(path, this::answer);
exchange.getRequestHeaders().getFirst("Authorization"), }
exchange.getRequestHeaders().getFirst("X-Requested-By"), }
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8); private void answer(HttpExchange exchange) throws IOException {
exchange.sendResponseHeaders(status, bytes.length); seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
exchange.getResponseBody().write(bytes); exchange.getRequestHeaders().getFirst("Authorization"),
exchange.close(); exchange.getRequestHeaders().getFirst("X-Requested-By"),
}); new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
} }
private GatewayHttpCommands commands(String token) { private GatewayHttpCommands commands(String token) {
@@ -216,6 +226,102 @@ class GatewayHttpCommandsTest {
assertEquals(seenBefore, seen.size()); assertEquals(seenBefore, seen.size());
} }
@Test
void refreshPostsToRefreshWithTheTokenAndParsesTheAnswer() throws Exception {
serve(200, Files.readString(Path.of("src/test/resources/api/refresh.json")));
var result = commands("s3cret-token").refresh();
assertEquals("POST", seen.getFirst().method());
assertEquals("/refresh", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals(List.of("deye", "house-meter"), result.requested());
assertEquals(new RefreshResult.Skipped("heat-pump", 41), result.skipped().getFirst());
}
@Test
void refreshFailsOnAnyOtherStatusOrWithoutToken() {
serve(401, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").refresh());
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).refresh());
assertEquals(seenBefore, seen.size());
}
@Test
void readsTheBatteryReserveWithTheToken() throws Exception {
serve(200, contract("battery-reserve.json"));
var reserve = commands("s3cret-token").reserve();
assertEquals(new BatteryView.Reserve(50, 30), reserve);
assertEquals("GET", seen.getFirst().method());
assertEquals("/battery/reserve", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
}
@Test
void sendsAScheduleChangeAsTheGatewayExpectsIt() throws Exception {
serve(200, contract("response-battery-applied.json"));
var change = new BatteryChange(true, List.of(new BatteryChange.Slot(2, LocalTime.of(9, 30), 90, true),
new BatteryChange.Slot(3, null, 85, null)));
var result = commands("s3cret-token").changeSchedule(change, "anna");
assertEquals(BatteryResult.Status.APPLIED, result.status());
assertEquals(java.util.Map.of(167, 90, 172, 1), result.read());
assertEquals("POST", seen.getFirst().method());
assertEquals("/battery/schedule", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals("anna", seen.getFirst().requestedBy());
assertEquals(contractJson("request-battery-schedule.json"), contractJson(seen.getFirst().body()));
}
@Test
void sendsAReserveChange() throws Exception {
serve(200, contract("response-battery-applied.json"));
commands("s3cret-token").changeReserve(60, "anna");
assertEquals("/battery/reserve", seen.getFirst().path());
assertEquals(contractJson("{\"pct\":60}"), contractJson(seen.getFirst().body()));
}
@Test
void everyBatteryStatusOfTheGatewayIsAVerdict() throws Exception {
var expected = java.util.Map.of(422, BatteryResult.Status.REJECTED, 409, BatteryResult.Status.STALE,
429, BatteryResult.Status.TOO_SOON, 502, BatteryResult.Status.FAILED);
for (var entry : expected.entrySet()) {
serve(entry.getKey(), "{\"status\":\"" + entry.getValue() + "\",\"message\":\"x\",\"read\":{}}");
assertEquals(entry.getValue(), commands("s3cret-token").changeReserve(60, "anna").status());
}
}
@Test
void aFailedBatteryWriteCarriesWhatWasRead() throws Exception {
serve(502, contract("response-battery-failed.json"));
var result = commands("s3cret-token").changeSchedule(new BatteryChange(null, null), "anna");
assertEquals(BatteryResult.Status.FAILED, result.status());
assertEquals(java.util.Map.of(167, 80), result.read());
}
@Test
void batteryCallsFailWithoutAVerdictOrToken() {
for (int status : List.of(400, 401, 404, 503)) {
serve(status, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").reserve(), "GET " + status);
assertThrows(IOException.class, () -> commands("s3cret-token").changeReserve(60, "anna"), "POST " + status);
}
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).reserve());
assertThrows(IOException.class, () -> commands(null).changeReserve(60, "anna"));
assertEquals(seenBefore, seen.size());
}
@Test @Test
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception { void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1," serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
@@ -2,15 +2,24 @@ package home.backend.adapters.http;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.application.AuditRepository;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService; import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService; import home.backend.application.EnergyService;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.config.PresetFile; import home.backend.adapters.config.PresetFile;
import home.backend.application.GatewayClient; import home.backend.application.GatewayClient;
import home.backend.application.GatewayBattery;
import home.backend.application.GatewayCommands; import home.backend.application.GatewayCommands;
import home.backend.application.SetpointService; import home.backend.application.SetpointService;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.Command; import home.backend.domain.Command;
import home.backend.domain.CommandResult; import home.backend.domain.CommandResult;
import home.backend.domain.CommandResult.Status; import home.backend.domain.CommandResult.Status;
import home.backend.domain.RefreshResult;
import home.backend.domain.RestoreResult; import home.backend.domain.RestoreResult;
import home.backend.domain.RestoreResult.Outcome; import home.backend.domain.RestoreResult.Outcome;
import home.backend.domain.Restore; import home.backend.domain.Restore;
@@ -19,10 +28,21 @@ import home.backend.domain.SetpointState;
import home.backend.application.HealthService; import home.backend.application.HealthService;
import home.backend.application.HeatPumpService; import home.backend.application.HeatPumpService;
import home.backend.application.HistoryRepository; import home.backend.application.HistoryRepository;
import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryService; import home.backend.application.HistoryService;
import home.backend.application.ProgramService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.application.WeatherService;
import home.backend.domain.Forecast;
import home.backend.domain.GatewaySnapshot; import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot; import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement; import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.WeatherView.Warning;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.Periods;
import home.backend.domain.HistoryView.Point; import home.backend.domain.HistoryView.Point;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -37,12 +57,16 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Clock; import java.time.Clock;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.TreeMap;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -57,12 +81,18 @@ class ApiServerTest {
private GatewaySnapshot snapshot; private GatewaySnapshot snapshot;
private boolean gatewayDown; private boolean gatewayDown;
private boolean historyDown; private boolean historyDown;
private boolean auditDown;
private HeatPumpSamples samples;
private RuntimeException gatewayBug; private RuntimeException gatewayBug;
private final List<Command> sent = new ArrayList<>(); private final List<Command> sent = new ArrayList<>();
private Status verdict; private Status verdict;
private Outcome restoreOutcome; private Outcome restoreOutcome;
private int refreshes;
private final List<String> restored = new ArrayList<>(); private final List<String> restored = new ArrayList<>();
private boolean commandsDown; private boolean commandsDown;
private boolean batteryDown;
private BatteryResult.Status batteryVerdict;
private final List<String> batterySent = new ArrayList<>();
private int answersBeforeDrop; private int answersBeforeDrop;
private ApiServer server; private ApiServer server;
@@ -86,6 +116,16 @@ class ApiServerTest {
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5), new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2)); new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
}; };
samples = (from, to) -> {
if (historyDown) {
throw new IOException("influx down");
}
return List.of(
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 8.0, 44.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 8.5, 43.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 9.0, 45.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 9.0, 46.0, Periods.UNKNOWN));
};
verdict = Status.APPLIED; verdict = Status.APPLIED;
restoreOutcome = Outcome.RESTORED; restoreOutcome = Outcome.RESTORED;
answersBeforeDrop = Integer.MAX_VALUE; answersBeforeDrop = Integer.MAX_VALUE;
@@ -130,11 +170,58 @@ class ApiServerTest {
return 0; return 0;
} }
}; };
batteryDown = false;
batteryVerdict = BatteryResult.Status.APPLIED;
GatewayBattery batteryCommands = new GatewayBattery() {
@Override
public Reserve reserve() throws IOException {
if (batteryDown) {
throw new IOException("gateway /battery/reserve answered 404");
}
return new Reserve(50, 30);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
return answer("schedule " + change + " by " + requestedBy);
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
return answer("reserve " + pct + " by " + requestedBy);
}
private BatteryResult answer(String what) throws IOException {
if (batteryDown) {
throw new IOException("tunnel down");
}
batterySent.add(what);
return switch (batteryVerdict) {
case APPLIED -> new BatteryResult(batteryVerdict, "applied", Map.of(167, 90));
case FAILED -> new BatteryResult(batteryVerdict,
"registers not as written: [167]; not retried", Map.of(167, 80));
default -> new BatteryResult(batteryVerdict, "nope", Map.of());
};
}
};
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC); Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock), server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
new EnergyDetailsService(gateway, clock), new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock), new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
new SetpointService(commands, PresetFile.load(null))); new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
new BatteryService(gateway, batteryCommands, clock),
new AuditService(auditRows(), clock), new StatsService(samples, clock),
new ProgramService((from, to) -> samples.samples(from, to), clock),
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
(from, to) -> exampleMeasuredPv(), clock),
() -> {
if (commandsDown) {
throw new IOException("tunnel down");
}
refreshes++;
return new RefreshResult(List.of("deye", "house-meter"),
List.of(new RefreshResult.Skipped("heat-pump", 41)));
}, Authentication.disabled());
server.start(); server.start();
} }
@@ -143,21 +230,45 @@ class ApiServerTest {
server.stop(); server.stop();
} }
private AuditRepository auditRows() {
return new AuditRepository() {
@Override
public List<Row> heatPump(Instant from, Instant to) throws IOException {
if (auditDown) {
throw new IOException("influx down");
}
return List.of(
new Row(Instant.parse("2026-10-24T08:00:00Z"), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
new Row(Instant.parse("2026-10-23T07:00:00Z"), "co_comfort", "mariusz", "REJECTED", null, 22.0));
}
@Override
public List<Row> battery(Instant from, Instant to) {
return List.of(
new Row(Instant.parse("2026-10-24T09:30:00Z"), "reserve", "mariusz", "APPLIED", 50.0, 60.0),
new Row(Instant.parse("2026-10-23T18:00:00Z"), "174", "mariusz", "FAILED", 0.0, 1.0));
}
};
}
private static GatewaySnapshot exampleSnapshot() { private static GatewaySnapshot exampleSnapshot() {
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2, Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3, "co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
"pump_outlet_temp", 19.7, "outdoor_avg_temp", 4.8, "target_room_temp", 18.0)); "pump_outlet_temp", 19.7, "outdoor_avg_temp", 4.8, "target_room_temp", 18.0));
pump.putAll(Map.of("target_co_temp", 31.0, "target_cwu_temp", 8.0, "pump_mode", 0.0, pump.putAll(Map.of("target_co_temp", 31.0, "target_cwu_temp", 8.0, "pump_mode", 0.0,
"output_compressor", 0.0, "output_heater", 0.0, "alarm", 0.0, "output_compressor", 0.0, "alarm", 0.0,
"setpoint_co_economy", 18.0, "setpoint_co_comfort", 19.0, "setpoint_cwu_economy", 8.0)); "setpoint_co_economy", 18.0, "setpoint_co_comfort", 19.0, "setpoint_cwu_economy", 8.0));
pump.putAll(Map.of("setpoint_cwu_comfort", 45.0, "output_co", 0.0, "output_buffer", 0.0, pump.putAll(Map.of("setpoint_cwu_comfort", 45.0, "output_co", 0.0, "output_buffer", 0.0,
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0)); "output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0, "program", 1.0));
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z"); Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z"); Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9, Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0, "grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3, "load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
"load_today_kwh", 24.2); "load_today_kwh", 24.2));
deye.putAll(Map.of("battery_voltage_v", 56.49, "battery_current_a", -0.05, "battery_temperature_c", 19.2,
"battery_charge_today_kwh", 0.5, "battery_discharge_today_kwh", 0.1,
"battery_charge_total_kwh", 453.7, "battery_discharge_total_kwh", 162.1));
return new GatewaySnapshot("v1.2.1", List.of( return new GatewaySnapshot("v1.2.1", List.of(
new DeviceSnapshot("heat-pump", true, pumpTime, null, new DeviceSnapshot("heat-pump", true, pumpTime, null,
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))), List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
@@ -169,7 +280,28 @@ class ApiServerTest {
new DeviceSnapshot("house-meter", true, energyTime, null, new DeviceSnapshot("house-meter", true, energyTime, null,
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69, List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75, "p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
"frequency", 50.01, "import_energy_active", 67514.27), energyTime))))); "frequency", 50.01, "import_energy_active", 67514.27, "total_power_active", 388.78), energyTime))),
new DeviceSnapshot("deye-settings", true, energyTime, null,
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
new DeviceSnapshot("bme280", true, energyTime, null,
List.of(new Measurement("measurement", "MEASUREMENT",
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8), energyTime)))));
}
/** The settings read from the inverter on 2026-10-24: time of use off, six 4-hour slots. */
private static Map<String, Double> deyeSettings() {
Map<String, Double> settings = new HashMap<>(Map.of("battery_capacity_ah", 280.0, "tou_enabled", 0.0,
"max_charge_current_a", 80.0, "max_discharge_current_a", 120.0, "shutdown_soc_pct", 20.0,
"low_soc_pct", 35.0, "restart_soc_pct", 50.0, "grid_charge_enabled", 1.0,
"grid_charge_current_a", 40.0, "grid_charge_start_soc_pct", 30.0));
double[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int slot = 1; slot <= 6; slot++) {
settings.put("prog" + slot + "_start_hhmm", starts[slot - 1]);
settings.put("prog" + slot + "_power_w", 10000.0);
settings.put("prog" + slot + "_soc_pct", 80.0);
settings.put("prog" + slot + "_grid_charge", 0.0);
}
return settings;
} }
private HttpResponse<String> get(String path) throws Exception { private HttpResponse<String> get(String path) throws Exception {
@@ -236,16 +368,227 @@ class ApiServerTest {
assertMatchesExample("/api/energy", "energy-deye-stale"); assertMatchesExample("/api/energy", "energy-deye-stale");
} }
@Test
void batteryMatchesExample() throws Exception {
assertMatchesExample("/api/battery", "battery");
}
@Test
void batteryWithStaleSettingsMatchesExample() throws Exception {
Instant old = Instant.parse("2026-10-23T21:00:00Z");
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
devices.set(devices.size() - 2, new DeviceSnapshot("deye-settings", true, old, null,
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), old))));
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/battery", "battery-settings-stale");
}
@Test
void batteryWithoutGatewayCommandsIsNotEditable() throws Exception {
batteryDown = true;
var body = JSON.readTree(get("/api/battery").body());
assertTrue(body.get("reserve").isNull());
assertEquals(false, body.get("editable").asBoolean());
assertEquals(99.0, body.get("socPct").asDouble());
}
@Test
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
var response = post("/api/battery/schedule", example("battery-schedule-request").toString());
assertEquals(200, response.statusCode());
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
new BatteryChange.Slot(2, java.time.LocalTime.of(9, 30), 90, true),
new BatteryChange.Slot(3, null, 85, null))) + " by phone"), batterySent);
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
JSON.readTree(response.body()));
}
@Test
void reserveChangeGoesToTheGateway() throws Exception {
var response = post("/api/battery/reserve", example("battery-reserve-request").toString());
assertEquals(200, response.statusCode());
assertEquals(List.of("reserve 60 by phone"), batterySent);
}
@Test
void batteryStatusFollowsTheGatewaysVerdict() throws Exception {
var expected = Map.of(BatteryResult.Status.REJECTED, 422, BatteryResult.Status.STALE, 409,
BatteryResult.Status.TOO_SOON, 429, BatteryResult.Status.FAILED, 502);
for (var entry : expected.entrySet()) {
batteryVerdict = entry.getKey();
assertEquals(entry.getValue(), post("/api/battery/reserve", "{\"pct\":60}").statusCode());
assertEquals(entry.getValue(), post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
}
batteryVerdict = BatteryResult.Status.FAILED;
assertEquals(example("battery-failed"),
JSON.readTree(post("/api/battery/schedule", "{\"enabled\":false}").body()));
}
@Test
void batteryChangeWithTheGatewayDownIs503() throws Exception {
batteryDown = true;
assertEquals(503, post("/api/battery/reserve", "{\"pct\":60}").statusCode());
assertEquals(503, post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
}
@Test
void badBatteryRequestsAre400AndSendNothing() throws Exception {
for (String body : List.of("", "not json", "[]", "{}", "{\"pct\":\"60\"}", "{\"pct\":60.5}", "{\"pct\":null}",
"{\"pct\":60,\"x\":1}")) {
assertEquals(400, post("/api/battery/reserve", body).statusCode(), body);
}
for (String body : List.of("", "not json", "[]", "{\"enabled\":\"yes\"}", "{\"slots\":[{\"slot\":1,\"from\":\"9h\"}]}",
"{\"slots\":[{\"slot\":1,\"from\":\"25:00\"}]}", "{\"slots\":[{\"slot\":1,\"socPct\":\"9\"}]}",
"{\"slots\":[{\"slot\":1,\"soc\":9}]}", "{\"slots\":[{\"slot\":1,\"gridCharge\":1}]}")) {
assertEquals(400, post("/api/battery/schedule", body).statusCode(), body);
}
assertTrue(batterySent.isEmpty());
}
@Test
void batteryWritesAreGuardedLikeEveryWrite() throws Exception {
for (String path : List.of("/api/battery/reserve", "/api/battery/schedule")) {
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode(), path);
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode(), path);
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode(), path);
assertEquals(405, get(path).statusCode(), path);
}
assertEquals(404, post("/api/battery/other", "{}").statusCode());
assertTrue(batterySent.isEmpty());
}
private static Forecast exampleForecast() {
Map<LocalDate, Forecast.DailyForecast> days = new TreeMap<>();
for (int i = 0; i < 5; i++) {
days.put(LocalDate.parse("2026-10-19").plusDays(i), new Forecast.DailyForecast(3, 12.0, 4.0, 0.0, 10.0, 7200.0));
}
days.put(LocalDate.parse("2026-10-24"), new Forecast.DailyForecast(3, 10.9, 3.8, 0.0, 9.0, 7151.0));
days.put(LocalDate.parse("2026-10-25"), new Forecast.DailyForecast(51, 13.2, 4.0, 0.3, 4.5, 28314.0));
days.put(LocalDate.parse("2026-10-26"), new Forecast.DailyForecast(61, 9.6, 4.1, 8.4, 2.1, 0.0));
return new Forecast(10.6, days);
}
/** Twice the forecast radiation of the same days, so the factor is 2 kWh per MJ/m². */
private static Map<LocalDate, Double> exampleMeasuredPv() {
Map<LocalDate, Double> measured = new HashMap<>();
for (int i = 0; i < 5; i++) {
measured.put(LocalDate.parse("2026-10-19").plusDays(i), 20.0);
}
return measured;
}
private static Warning exampleWarning() {
return new Warning("Silny wiatr", 2, OffsetDateTime.parse("2026-10-25T08:00:00+01:00"),
OffsetDateTime.parse("2026-10-25T18:00:00+01:00"),
"Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h.");
}
@Test
void weatherMatchesExample() throws Exception {
assertMatchesExample("/api/weather", "weather");
}
@Test
void piSensorMatchesExample()throws Exception {
assertMatchesExample("/api/pi-sensor", "pi-sensor");
}
@Test
void stalePiSensorMatchesExample() throws Exception {
Instant old = Instant.parse("2026-10-23T21:00:00Z");
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
Measurement bme = devices.getLast().measurements().get(0);
devices.set(devices.size() - 1, new DeviceSnapshot("bme280", true, old, null,
List.of(new Measurement(bme.database(), bme.measurement(), bme.fields(), old))));
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/pi-sensor", "pi-sensor-stale");
}
@Test @Test
void historyMatchesExample() throws Exception { void historyMatchesExample() throws Exception {
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history"); assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
} }
@Test
void statsMatchExample() throws Exception {
assertMatchesExample("/api/stats?range=7d", "stats");
}
@Test
void statsNeedAKnownRange() throws Exception {
assertEquals(400, get("/api/stats").statusCode());
assertEquals(400, get("/api/stats?range=24h").statusCode());
assertEquals(200, get("/api/stats?range=30d").statusCode());
}
@Test
void statsAre502WhenInfluxIsDown() throws Exception {
historyDown = true;
assertEquals(502, get("/api/stats?range=7d").statusCode());
}
@Test
void programMatchesExample() throws Exception {
// Friday 2026-10-23, CEST: CWU comfort from 11:25 (09:25 UTC) to 12:05.
Periods economy = Periods.of(1, new Targets(18.0, 31.0, 8.0), new Setpoints(18, 19, 8, 43));
Periods comfort = Periods.of(1, new Targets(18.0, 31.0, 43.0), new Setpoints(18, 19, 8, 43));
samples = (from, to) -> {
List<HeatPumpSample> polls = new ArrayList<>();
for (int minute = 20; minute <= 125; minute += 5) {
Instant t = Instant.parse("2026-10-23T09:00:00Z").plus(Duration.ofMinutes(minute));
polls.add(new HeatPumpSample(t, null, null, null, null, null,
minute >= 25 && minute <= 60 ? comfort : economy));
}
return polls;
};
assertMatchesExample("/api/program?days=7", "program");
}
@Test
void programNeedsDaysFromSevenToThirty() throws Exception {
assertEquals(200, get("/api/program").statusCode());
assertEquals(400, get("/api/program?days=3").statusCode());
assertEquals(400, get("/api/program?days=x").statusCode());
}
@Test
void programIs502WhenInfluxIsDown() throws Exception {
historyDown = true;
assertEquals(502, get("/api/program").statusCode());
}
@Test @Test
void historyRangeDefaultsTo24Hours() throws Exception { void historyRangeDefaultsTo24Hours() throws Exception {
assertEquals(200, get("/api/history?series=cwu_temp").statusCode()); assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
} }
@Test
void auditMatchesExample() throws Exception {
assertMatchesExample("/api/audit?range=7d", "audit");
}
@Test
void auditRangeDefaultsAndIsChecked() throws Exception {
assertEquals(200, get("/api/audit").statusCode());
assertEquals(400, get("/api/audit?range=90d").statusCode());
}
@Test
void auditInfluxFailureIs502() throws Exception {
auditDown = true;
assertEquals(502, get("/api/audit?range=7d").statusCode());
}
@Test @Test
void unreachableGatewayIs503WithJsonError() throws Exception { void unreachableGatewayIs503WithJsonError() throws Exception {
gatewayDown = true; gatewayDown = true;
@@ -337,11 +680,11 @@ class ApiServerTest {
@Test @Test
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception { void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna"); HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}");
assertEquals(200, response.statusCode()); assertEquals(200, response.statusCode());
assertEquals(example("setpoint-restored"), JSON.readTree(response.body())); assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
assertEquals(List.of("cwu_economy by anna"), restored); assertEquals(List.of("cwu_economy by phone"), restored);
} }
@Test @Test
@@ -372,6 +715,32 @@ class ApiServerTest {
assertTrue(restored.isEmpty()); assertTrue(restored.isEmpty());
} }
@Test
void refreshPassesTheGatewaysAnswerThrough() throws Exception {
var response = post("/api/refresh", "{}");
assertEquals(200, response.statusCode());
assertEquals(example("refresh"), JSON.readTree(response.body()));
assertEquals(1, refreshes);
}
@Test
void refreshIs503WhenTheGatewayCannotBeReached() throws Exception {
commandsDown = true;
assertEquals(503, post("/api/refresh", "{}").statusCode());
}
@Test
void refreshIsGuardedLikeEveryWrite() throws Exception {
String path = "/api/refresh";
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
assertEquals(405, get(path).statusCode());
assertEquals(0, refreshes);
}
@Test @Test
void otherPathsUnderSetpointsAre404() throws Exception { void otherPathsUnderSetpointsAre404() throws Exception {
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy", for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
@@ -399,12 +768,10 @@ class ApiServerTest {
} }
@Test @Test
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception { void aRemoteUserHeaderIsNotTrusted() throws Exception {
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl"); post("/api/setpoints", BOOST, "Remote-User", "anna");
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
post("/api/setpoints", BOOST);
assertEquals(List.of("anna.k@example.pl", "phone", "phone"), sent.stream().map(Command::requestedBy).toList()); assertEquals(List.of("phone"), sent.stream().map(Command::requestedBy).toList());
} }
@Test @Test
@@ -451,6 +818,35 @@ class ApiServerTest {
new Command("co_comfort", 15, null, end, "phone")), sent); new Command("co_comfort", 15, null, end, "phone")), sent);
} }
@Test
void awayWithOwnValuesSendsThem() throws Exception {
var response = post("/api/presets/away", example("preset-away-values-request").toString());
assertEquals(200, response.statusCode());
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
assertEquals(List.of(new Command("cwu_comfort", 12, null, end, "phone"),
new Command("co_comfort", 16, null, end, "phone")), sent);
}
@Test
void ownValuesForOtherSetpointsOrOutsideTheLimitsAre400AndSendNothing() throws Exception {
String until = "\"until\":\"2026-11-02T18:00:00Z\"";
for (String values : List.of("{\"cwu_economy\":6}", "{\"co_comfort\":4}", "{\"co_comfort\":26}",
"{\"co_comfort\":null}", "{\"co_comfort\":15.5}", "{\"co_comfort\":\"15\"}", "[]")) {
assertEquals(400, post("/api/presets/away", "{" + until + ",\"values\":" + values + "}").statusCode(),
values);
}
assertTrue(sent.isEmpty());
}
@Test
void ownValueThatDoesNotLowerAHeldSetpointIs422() throws Exception {
var body = "{\"until\":\"2026-11-02T18:00:00Z\",\"values\":{\"co_comfort\":19}}";
assertEquals(422, post("/api/presets/away", body).statusCode());
assertTrue(sent.isEmpty());
}
@Test @Test
void badOrMissingEndTimesAre400AndSendNothing() throws Exception { void badOrMissingEndTimesAre400AndSendNothing() throws Exception {
for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}", for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}",
@@ -467,6 +863,25 @@ class ApiServerTest {
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode()); assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
} }
@Test
void timedPresetTakesOwnHoursAndValue() throws Exception {
var response = post("/api/presets/cwu-now", example("preset-cwu-now-request").toString());
assertEquals(200, response.statusCode());
assertEquals(List.of(new Command("cwu_economy", 47, Duration.ofHours(3), "phone")), sent);
}
@Test
void invalidHoursAre400AndSendNothing() throws Exception {
for (String hours : List.of("0", "5", "-1", "2.5", "\"2\"")) {
assertEquals(400, post("/api/presets/cwu-now", "{\"hours\":" + hours + "}").statusCode(), hours);
}
var held = post("/api/presets/away", "{\"until\":\"2026-11-02T18:00:00Z\",\"hours\":2}");
assertEquals(400, held.statusCode());
assertEquals("invalid_hours", JSON.readTree(held.body()).get("error").asText());
assertTrue(sent.isEmpty());
}
@Test @Test
void unknownPresetIs404() throws Exception { void unknownPresetIs404() throws Exception {
assertEquals(404, post("/api/presets/nope", "").statusCode()); assertEquals(404, post("/api/presets/nope", "").statusCode());
@@ -0,0 +1,105 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import home.backend.application.AuditRepository.Row;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InfluxAuditRepositoryTest {
private static final Instant FROM = Instant.parse("2026-10-17T12:00:00Z");
private static final Instant TO = Instant.parse("2026-10-24T12:00:00Z");
private HttpServer server;
private final List<String> queries = Collections.synchronizedList(new ArrayList<>());
private String body;
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/query", exchange -> {
queries.add(URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
}
@AfterEach
void stopServer() {
server.stop(0);
}
@Test
void readsHeatPumpRowsFromTheLegacyDatabase() throws IOException {
body = fixture("influx-audit-setpoints.json");
List<Row> rows = repository().heatPump(FROM, TO);
assertEquals(2, rows.size());
assertEquals(new Row(Instant.ofEpochMilli(1761292800000L), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
rows.get(0));
assertNull(rows.get(1).oldValue());
assertEquals("co_comfort", rows.get(1).target());
assertTrue(queries.getFirst().startsWith("db=Dom_Pompa&epoch=ms&q=SELECT * FROM \"setpoint_audit\" "
+ "WHERE time >= " + FROM.toEpochMilli() + "ms AND time < " + TO.toEpochMilli() + "ms"),
queries.getFirst());
}
@Test
void readsBatteryRowsByRegisterTag() throws IOException {
body = fixture("influx-audit-battery.json");
List<Row> rows = repository().battery(FROM, TO);
assertEquals(List.of("reserve", "174"), rows.stream().map(Row::target).toList());
assertEquals(60.0, rows.getFirst().newValue());
assertTrue(queries.getFirst().startsWith("db=electricity&"), queries.getFirst());
assertTrue(queries.getFirst().contains("\"deye_settings_audit\""), queries.getFirst());
}
@Test
void noRowsIsAnEmptyList() throws IOException {
body = fixture("influx-query-empty.json");
assertTrue(repository().battery(FROM, TO).isEmpty());
}
@Test
void anInfluxErrorIsAnIOException() throws IOException {
body = fixture("influx-query-error.json");
assertThrows(IOException.class, () -> repository().heatPump(FROM, TO));
}
private InfluxAuditRepository repository() {
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
return new InfluxAuditRepository(new InfluxSettings(base, Duration.ofSeconds(5)));
}
private static String fixture(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/influx", name));
}
}
@@ -1,6 +1,9 @@
package home.backend.adapters.influx; package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.Period;
import home.backend.domain.Periods;
import home.backend.domain.HistoryView.Point; import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series; import home.backend.domain.Series;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
@@ -94,6 +97,15 @@ class InfluxHistoryRepositoryTest {
assertEquals(new Point(Instant.ofEpochMilli(1792771200000L), 43.76666666666667), points.get(0)); assertEquals(new Point(Instant.ofEpochMilli(1792771200000L), 43.76666666666667), points.get(0));
} }
@Test
void asksForGroundInAndPumpOutletFromTheirLegacyFields() throws IOException {
repository(TIMEOUT).history(Series.GROUND_IN_TEMP, FROM, TO, 360);
repository(TIMEOUT).history(Series.PUMP_OUTLET_TEMP, FROM, TO, 360);
assertTrue(requests.get(0).get("q").startsWith("SELECT mean(\"Temp in\") FROM \"POMPA\""), requests.get(0).get("q"));
assertTrue(requests.get(1).get("q").startsWith("SELECT mean(\"Temp pc out\") FROM \"POMPA\""), requests.get(1).get("q"));
}
@Test @Test
void asksTheElectricityDatabaseForGridPower() throws IOException { void asksTheElectricityDatabaseForGridPower() throws IOException {
repository(TIMEOUT).history(Series.GRID_POWER, FROM, TO, 72); repository(TIMEOUT).history(Series.GRID_POWER, FROM, TO, 72);
@@ -208,6 +220,38 @@ class InfluxHistoryRepositoryTest {
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72)); () -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
} }
@Test
void readsRawHeatPumpPollsFromTheLegacyDatabase() throws IOException {
defaultBody = fixture("influx-query-heat-pump.json");
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
assertEquals("Dom_Pompa", requests.get(0).get("db"));
assertEquals("SELECT output_compressor, output_cwu, output_co, outdoor_temp, cwu_temp,"
+ " program, target_room_temp, target_cwu_temp, setpoint_co_economy, setpoint_co_comfort,"
+ " setpoint_cwu_economy, setpoint_cwu_comfort"
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
requests.get(0).get("q"));
assertEquals(3, samples.size());
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 8.5, 43.9,
new Periods(Period.COMFORT, Period.COMFORT, 1, null)), samples.get(1));
assertEquals(Period.ECONOMY, samples.get(0).periods().cwu());
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, null, null, Periods.UNKNOWN),
samples.get(2));
}
@Test
void noHeatPumpPollsIsAnEmptyList() throws IOException {
assertTrue(repository(TIMEOUT).samples(FROM, TO).isEmpty());
}
@Test
void heatPumpQueryErrorIsAnIoException() throws IOException {
defaultBody = fixture("influx-query-error.json");
assertThrows(IOException.class, () -> repository(TIMEOUT).samples(FROM, TO));
}
@Test @Test
void rejectsAnEmptyRange() { void rejectsAnEmptyRange() {
assertThrows(IllegalArgumentException.class, assertThrows(IllegalArgumentException.class,
@@ -0,0 +1,89 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InfluxPvEnergyTest {
private static final LocalDate FROM = LocalDate.parse("2026-10-19");
private static final LocalDate TO = LocalDate.parse("2026-10-24");
private HttpServer server;
private InfluxPvEnergy pv;
private final List<String> queries = new ArrayList<>();
private int status = 200;
/** Day buckets start at Warsaw midnight: 2026-10-19 is 2026-10-18T22:00Z. */
private String deye = rows("1792360800000", 22.5, "1792447200000", 8.0);
private String fronius = rows("1792360800000", 4400.0, "1792533600000", 100.0);
@BeforeEach
void start() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/query", exchange -> {
String query = URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8);
queries.add(query);
byte[] bytes = (query.contains("\"deye\"") ? deye : fronius).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
var settings = new InfluxSettings(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
Duration.ofSeconds(2));
pv = new InfluxPvEnergy(settings);
}
@AfterEach
void stop() {
server.stop(0);
}
private static String rows(String t1, double v1, String t2, double v2) {
return "{\"results\":[{\"statement_id\":0,\"series\":[{\"name\":\"m\",\"columns\":[\"time\",\"max\"],"
+ "\"values\":[[" + t1 + "," + v1 + "],[" + t2 + "," + v2 + "]]}]}]}";
}
@Test
void addsTheDeyeAndFroniusDayCountersPerLocalDay() throws IOException {
Map<LocalDate, Double> days = pv.dailyKwh(FROM, TO);
assertEquals(Map.of(FROM, 22.5 + 4.4), days);
}
@Test
void asksForTheDailyMaximumOfEachCounterInWarsawDays() throws IOException {
pv.dailyKwh(FROM, TO);
assertEquals(2, queries.size());
String deyeQuery = queries.stream().filter(q -> q.contains("\"deye\"")).findFirst().orElseThrow();
assertTrue(deyeQuery.contains("db=electricity"), deyeQuery);
assertTrue(deyeQuery.contains("SELECT max(\"pv_today_kwh\") FROM \"deye\" WHERE time >= 1792360800000ms"
+ " AND time < 1792792800000ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')"), deyeQuery);
assertTrue(queries.stream().anyMatch(q -> q.contains("max(\"energy_day_wh\") FROM \"fronius\"")), queries.toString());
}
@Test
void failureIsAnIoError() {
status = 500;
assertThrows(IOException.class, () -> pv.dailyKwh(FROM, TO));
}
}
@@ -0,0 +1,185 @@
package home.backend.adapters.weather;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.Forecast;
import home.backend.domain.WeatherView.Warning;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** The two clients against a local server that answers with responses recorded from the real services. */
class WeatherClientsTest {
private static final WeatherSettings PLACE = WeatherSettings.fromEnvironment(Map.of());
private static final Duration TIMEOUT = Duration.ofSeconds(2);
private HttpServer server;
private URI base;
private final List<String> requests = new ArrayList<>();
private int status = 200;
private String body = "";
@BeforeEach
void start() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/", exchange -> {
requests.add(exchange.getRequestURI().toString());
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
}
@AfterEach
void stop() {
server.stop(0);
}
private static String fixture(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/weather", name));
}
@Test
void readsTheOpenMeteoForecastForTheHouse() throws IOException {
body = fixture("open-meteo.json");
Forecast forecast = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast();
assertEquals(10.6, forecast.temperature());
assertEquals(17, forecast.days().size());
var today = forecast.days().get(LocalDate.parse("2026-10-24"));
assertEquals(3, today.weatherCode());
assertEquals(10.9, today.tMax());
assertEquals(3.8, today.tMin());
assertEquals(6.88, today.radiationMj());
assertEquals(7151.32, today.sunshineSeconds());
assertEquals(8.4, forecast.days().get(LocalDate.parse("2026-10-26")).precipitationMm());
String request = requests.getFirst();
for (String part : List.of("latitude=49.684", "longitude=21.77", "timezone=Europe%2FWarsaw",
"past_days=14", "shortwave_radiation_sum", "sunshine_duration")) {
assertEquals(true, request.contains(part), part + " in " + request);
}
}
@Test
void aMissingValueInTheForecastIsNull() throws IOException {
body = """
{"daily":{"time":["2026-10-24"],"weather_code":[null],"temperature_2m_max":[1.5]}}""";
var day = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast().days().get(LocalDate.parse("2026-10-24"));
assertNull(day.weatherCode());
assertEquals(1.5, day.tMax());
assertNull(day.tMin());
assertNull(day.radiationMj());
}
@Test
void anOpenMeteoFailureIsAnIoError() {
body = "{\"error\":true}";
status = 500;
var client = new OpenMeteoClient(base, PLACE, TIMEOUT);
assertThrows(IOException.class, client::forecast);
status = 200;
body = "not json";
assertThrows(IOException.class, client::forecast);
body = "{\"daily\":{}}";
assertThrows(IOException.class, client::forecast);
}
@Test
void keepsWarningsThatCoverTheCityOrThePowiatAndMapsThem() throws IOException {
body = fixture("imgw-warnings.json");
List<Warning> warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings();
assertEquals("/api/data/warningsmeteo", requests.getFirst());
assertEquals(2, warnings.size());
Warning city = warnings.get(0);
assertEquals("Silny wiatr", city.event());
assertEquals(2, city.level());
assertEquals(OffsetDateTime.parse("2026-10-25T08:00:00+01:00"), city.from());
assertEquals(OffsetDateTime.parse("2026-10-25T18:00:00+01:00"), city.to());
assertEquals("Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h, "
+ "w porywach do 90 km/h, z południa.", city.text());
assertEquals(1, warnings.get(1).level());
}
@Test
void otherAreasAreIgnoredAndTerytIsConfigurable() throws IOException {
body = fixture("imgw-warnings.json");
var cityOnly = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("1862"));
var elsewhere = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("2804"));
assertEquals(1, new ImgwClient(base, cityOnly, TIMEOUT).warnings().size());
assertEquals(1, new ImgwClient(base, elsewhere, TIMEOUT).warnings().size());
assertEquals(0, new ImgwClient(base, new WeatherSettings(0, 0, List.of("0000")), TIMEOUT).warnings().size());
}
@Test
void aMalformedWarningIsSkippedNotFatal() throws IOException {
body = """
[{"nazwa_zdarzenia":"Burze","stopien":"x","obowiazuje_od":"2026-10-25 08:00:00",
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"a","teryt":["1862"]},
{"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"soon",
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"b","teryt":["1862"]},
{"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"2026-10-25 08:00:00",
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"c","teryt":["1862"]}]""";
List<Warning> warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings();
assertEquals(1, warnings.size());
assertEquals("c", warnings.getFirst().text());
}
@Test
void anImgwFailureIsAnIoError() {
body = "{}";
var client = new ImgwClient(base, PLACE, TIMEOUT);
assertThrows(IOException.class, client::warnings);
status = 503;
assertThrows(IOException.class, client::warnings);
}
@Test
void settingsDefaultToKrosnoAndRejectNonsense() {
assertEquals(49.684, PLACE.latitude());
assertEquals(21.770, PLACE.longitude());
assertEquals(List.of("1862", "1807"), PLACE.teryt());
var custom = WeatherSettings.fromEnvironment(
Map.of("WEATHER_LAT", "50.1", "WEATHER_LON", "-20", "WEATHER_TERYT", "1465, 1401"));
assertEquals(50.1, custom.latitude());
assertEquals(-20.0, custom.longitude());
assertEquals(List.of("1465", "1401"), custom.teryt());
assertThrows(IllegalArgumentException.class,
() -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LAT", "north")));
assertThrows(IllegalArgumentException.class,
() -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LON", "200")));
}
}
@@ -0,0 +1,108 @@
package home.backend.application;
import home.backend.application.AuditRepository.Row;
import home.backend.domain.AuditEntry;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class AuditServiceTest {
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
private List<Row> heatPump = List.of();
private List<Row> battery = List.of();
private final List<Instant> asked = new ArrayList<>();
private final AuditRepository repository = new AuditRepository() {
@Override
public List<Row> heatPump(Instant from, Instant to) {
asked.add(from);
return heatPump;
}
@Override
public List<Row> battery(Instant from, Instant to) throws IOException {
if (battery == null) {
throw new IOException("influx down");
}
return battery;
}
};
private final AuditService service = new AuditService(repository, CLOCK);
private static Row row(String time, String target, String by, Double old, Double value) {
return new Row(Instant.parse(time), target, by, "APPLIED", old, value);
}
@Test
void mergesBothSourcesNewestFirst() throws IOException {
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "a", 8.0, 45.0),
row("2026-10-22T08:00:00Z", "co_comfort", "a", 19.0, 20.0));
battery = List.of(row("2026-10-23T08:00:00Z", "reserve", "b", 50.0, 60.0));
var entries = service.audit(null).entries();
assertEquals(List.of("Ciepła woda – oszczędnie", "Rezerwa baterii", "Ogrzewanie – komfort"),
entries.stream().map(AuditEntry::what).toList());
assertEquals(List.of("heat_pump", "battery", "heat_pump"),
entries.stream().map(AuditEntry::source).toList());
}
@Test
void namesTheDeyeRegisters() throws IOException {
battery = List.of("146", "148", "153", "166", "171", "172", "177", "reserve", "999").stream()
.map(register -> row("2026-10-24T08:00:00Z", register, "a", 0.0, 1.0)).toList();
var entries = service.audit("24h").entries();
assertEquals(List.of("Harmonogram baterii/0/1", "Przedział 1: start/HHMM", "Przedział 6: start/HHMM",
"Przedział 1: poziom/%", "Przedział 6: poziom/%", "Przedział 1: ładowanie z sieci/0/1",
"Przedział 6: ładowanie z sieci/0/1", "Rezerwa baterii/%", "Rejestr 999/"),
entries.stream().map(e -> e.what() + "/" + e.unit()).toList());
}
@Test
void showsARestoreAsAutomaticAndKeepsMissingValuesNull() throws IOException {
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "restore", null, 8.4));
var entry = service.audit("7d").entries().getFirst();
assertEquals("automatycznie (przywrócenie)", entry.who());
assertNull(entry.oldValue());
assertEquals(8, entry.newValue());
assertEquals("°C", entry.unit());
}
@Test
void defaultsToSevenDaysAndAllowsUpToThirty() throws IOException {
service.audit(null);
service.audit("30d");
assertEquals(List.of(NOW.minus(Duration.ofDays(7)), NOW.minus(Duration.ofDays(30))), asked);
}
@Test
void rejectsAnUnknownRange() {
assertThrows(IllegalArgumentException.class, () -> service.audit("90d"));
assertThrows(IllegalArgumentException.class, () -> service.audit(""));
}
@Test
void failsWhenOneSourceFails() {
battery = null;
assertThrows(IOException.class, () -> service.audit("7d"));
}
}
@@ -0,0 +1,278 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.Periods;
import home.backend.domain.ProgramView;
import home.backend.domain.ProgramView.Circuit;
import home.backend.domain.ProgramView.Confidence;
import home.backend.domain.ProgramView.Interval;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ProgramServiceTest {
/** Tuesday 2026-10-27 12:00 in Warsaw; the Sunday before it, 10-25, is the day of the change to winter time. */
private static final Instant NOW = Instant.parse("2026-10-27T11:00:00Z");
private static final int POLL_MINUTES = 5;
private Instant askedFrom;
private Instant askedTo;
private ProgramView program(String days, List<HeatPumpSample> samples) throws IOException {
HeatPumpSamples repository = (from, to) -> {
askedFrom = from;
askedTo = to;
return samples;
};
return new ProgramService(repository, Clock.fixed(NOW, ZoneOffset.UTC)).program(days);
}
private static HeatPumpSample poll(String utc, Periods periods) {
return new HeatPumpSample(Instant.parse(utc), null, null, null, null, null, periods);
}
/** CWU economy 8, comfort 43; room economy 18, comfort 19, as on the real controller. */
private static Periods weekly(double cwuTarget, double roomTarget) {
return program(1, cwuTarget, roomTarget, 8, 43);
}
private static Periods program(int program, double cwuTarget, double roomTarget, int cwuEconomy, int cwuComfort) {
return Periods.of(program, new Targets(roomTarget, 31.0, cwuTarget),
new Setpoints(18, 19, cwuEconomy, cwuComfort));
}
private static Periods economy() {
return weekly(8.0, 18.0);
}
private static Periods cwuComfort() {
return weekly(43.0, 18.0);
}
/** Polls every 5 minutes from {@code from} to {@code to} (UTC, "2026-10-19T09:00"), all of them {@code periods}. */
private static List<HeatPumpSample> polls(String from, String to, Periods periods) {
List<HeatPumpSample> samples = new ArrayList<>();
Instant end = Instant.parse(to + ":00Z");
for (Instant t = Instant.parse(from + ":00Z"); !t.isAfter(end); t = t.plus(Duration.ofMinutes(POLL_MINUTES))) {
samples.add(new HeatPumpSample(t, null, null, null, null, null, periods));
}
return samples;
}
@SafeVarargs
private static List<HeatPumpSample> concat(List<HeatPumpSample>... parts) {
return Arrays.stream(parts).flatMap(List::stream).toList();
}
/** "2026-10-19T10:25" for a local time of that date, given the offset from UTC. */
private static String utc(String date, String local, int offsetHours, int plusMinutes) {
int total = Integer.parseInt(local.substring(0, 2)) * 60 + Integer.parseInt(local.substring(3))
- offsetHours * 60 + plusMinutes;
return String.format("%sT%02d:%02d", date, total / 60, total % 60);
}
/** CWU economy, then comfort from {@code from} to {@code to} (local), then economy, polled every 5 minutes. */
private static List<HeatPumpSample> cwuDay(String date, String from, String to, int offsetHours) {
return concat(
polls(utc(date, from, offsetHours, -30), utc(date, from, offsetHours, -5), economy()),
polls(utc(date, from, offsetHours, 0), utc(date, to, offsetHours, -5), cwuComfort()),
polls(utc(date, to, offsetHours, 0), utc(date, to, offsetHours, 30), economy()));
}
private static Circuit monday(ProgramView view) {
return view.weekdays().get(0).cwu();
}
@Test
void asksForWholeLocalDaysBeforeToday() throws IOException {
ProgramView view = program("14", List.of());
assertEquals(14, view.days());
assertEquals(Instant.parse("2026-10-12T22:00:00Z"), askedFrom);
assertEquals(Instant.parse("2026-10-26T23:00:00Z"), askedTo);
}
@Test
void defaultsToFourteenDaysAndListsMondayToSunday() throws IOException {
ProgramView view = program(null, List.of());
assertEquals(14, view.days());
assertEquals(List.of(1, 2, 3, 4, 5, 6, 7), view.weekdays().stream().map(ProgramView.Weekday::day).toList());
}
@Test
void rejectsDaysOutsideSevenToThirty() {
for (String days : new String[] {"6", "31", "abc", ""}) {
assertThrows(IllegalArgumentException.class, () -> program(days, List.of()), days);
}
}
@Test
void noDataGivesNoDataForEveryWeekday() throws IOException {
Circuit circuit = monday(program("14", List.of()));
assertEquals(Confidence.NO_DATA, circuit.confidence());
assertEquals(0, circuit.observedDays());
assertEquals(List.of(), circuit.comfort());
}
@Test
void aBoundaryIsHalfwayBetweenTheTwoPollsRoundedToFiveMinutes() throws IOException {
// Economy at 11:23:48 local (CEST), comfort at 11:28:48: the change was about 11:26, so 11:25.
// Comfort at 12:04:56, economy at 12:09:56: about 12:07, so 12:05.
List<HeatPumpSample> samples = concat(
List.of(poll("2026-10-19T09:23:48Z", economy()), poll("2026-10-19T09:28:48Z", cwuComfort())),
polls("2026-10-19T09:33", "2026-10-19T10:00", cwuComfort()),
List.of(poll("2026-10-19T10:04:56Z", cwuComfort()), poll("2026-10-19T10:09:56Z", economy())));
Circuit circuit = monday(program("14", samples));
assertEquals(List.of(new Interval("11:25", "12:05")), circuit.comfort());
assertEquals(Confidence.SEEN_ONCE, circuit.confidence());
assertEquals(1, circuit.observedDays());
}
@Test
void sameIntervalsOnTwoMondaysWithinFiveMinutesAreConfirmed() throws IOException {
List<HeatPumpSample> samples = concat(
cwuDay("2026-10-19", "11:25", "12:05", 2), cwuDay("2026-10-26", "11:30", "12:05", 1));
Circuit circuit = monday(program("14", samples));
assertEquals(Confidence.CONFIRMED, circuit.confidence());
assertEquals(2, circuit.observedDays());
assertEquals(2, circuit.agreeingDays());
}
@Test
void differentIntervalsOnTwoMondaysAreSeenOnceAndTheLatestIsShown() throws IOException {
List<HeatPumpSample> samples = concat(
cwuDay("2026-10-19", "11:25", "12:05", 2), cwuDay("2026-10-26", "13:00", "14:00", 1));
Circuit circuit = monday(program("14", samples));
assertEquals(Confidence.SEEN_ONCE, circuit.confidence());
assertEquals(2, circuit.observedDays());
assertEquals(1, circuit.agreeingDays());
assertEquals(List.of(new Interval("13:00", "14:00")), circuit.comfort());
}
@Test
void theMajorityPatternWinsOverOneOddDay() throws IOException {
List<HeatPumpSample> samples = concat(
cwuDay("2026-10-12", "11:25", "12:05", 2), cwuDay("2026-10-19", "11:25", "12:05", 2),
cwuDay("2026-10-26", "13:00", "14:00", 1));
Circuit circuit = monday(program("30", samples));
assertEquals(Confidence.CONFIRMED, circuit.confidence());
assertEquals(List.of(new Interval("11:25", "12:05")), circuit.comfort());
}
@Test
void aDayWithTheChangeToWinterTimeKeepsLocalClockTimes() throws IOException {
// Sunday 2026-10-18 is CEST (UTC+2), Sunday 2026-10-25 CET (UTC+1): the same local 11:25-12:05.
List<HeatPumpSample> samples = concat(
cwuDay("2026-10-18", "11:25", "12:05", 2), cwuDay("2026-10-25", "11:25", "12:05", 1));
Circuit sunday = program("14", samples).weekdays().get(6).cwu();
assertEquals(Confidence.CONFIRMED, sunday.confidence());
assertEquals(List.of(new Interval("11:25", "12:05")), sunday.comfort());
}
@Test
void anIntervalAcrossMidnightIsSplitOverBothDays() throws IOException {
// Comfort from 22:55 local Sunday to 01:05 local Monday (CET: UTC+1).
List<HeatPumpSample> samples = concat(
polls("2026-11-01T21:50", "2026-11-01T21:50", economy()),
polls("2026-11-01T21:55", "2026-11-02T00:00", cwuComfort()),
polls("2026-11-02T00:05", "2026-11-02T00:10", economy()));
Clock later = Clock.fixed(Instant.parse("2026-11-04T11:00:00Z"), ZoneOffset.UTC);
ProgramView view = new ProgramService((from, to) -> samples, later).program("14");
assertEquals(List.of(new Interval("22:55", "24:00")), view.weekdays().get(6).cwu().comfort());
assertEquals(List.of(new Interval("00:00", "01:05")), view.weekdays().get(0).cwu().comfort());
}
@Test
void ourOwnWritesLeaveNoComfortIntervalButTheDayStaysObserved() throws IOException {
// Economy raised 8 -> 45 -> 43 (equal to comfort, so the periods are ambiguous) -> 8, as in the
// 2026-10-24 tests, all during the economy period.
List<HeatPumpSample> samples = List.of(
poll("2026-10-19T11:00:00Z", program(1, 8.0, 18.0, 8, 43)),
poll("2026-10-19T11:05:00Z", program(1, 45.0, 18.0, 45, 43)),
poll("2026-10-19T11:10:00Z", program(1, 43.0, 18.0, 43, 43)),
poll("2026-10-19T11:15:00Z", program(1, 43.0, 18.0, 43, 43)),
poll("2026-10-19T11:20:00Z", program(1, 8.0, 18.0, 8, 43)));
Circuit circuit = monday(program("14", samples));
assertEquals(List.of(), circuit.comfort());
assertEquals(1, circuit.observedDays());
}
@Test
void aWriteInsideComfortDropsTheIntervalInsteadOfGuessingItsEnd() throws IOException {
// Economy written 8 -> 43 inside the comfort period makes the CWU periods ambiguous for two polls.
List<HeatPumpSample> samples = concat(
polls("2026-10-19T09:55", "2026-10-19T09:55", economy()),
polls("2026-10-19T10:00", "2026-10-19T10:05", cwuComfort()),
polls("2026-10-19T10:10", "2026-10-19T10:15", program(1, 43.0, 18.0, 43, 43)),
polls("2026-10-19T10:20", "2026-10-19T10:55", cwuComfort()),
polls("2026-10-19T11:00", "2026-10-19T11:05", economy()));
Circuit circuit = monday(program("14", samples));
assertEquals(List.of(), circuit.comfort());
assertEquals(1, circuit.observedDays());
}
@Test
void anotherProgramBreaksTheChain() throws IOException {
List<HeatPumpSample> samples = List.of(
poll("2026-10-19T09:55:00Z", economy()), poll("2026-10-19T10:00:00Z", cwuComfort()),
poll("2026-10-19T10:05:00Z", program(2, 43.0, 18.0, 8, 43)),
poll("2026-10-19T10:10:00Z", economy()));
Circuit circuit = monday(program("14", samples));
assertEquals(List.of(), circuit.comfort());
}
@Test
void aGapInTheDataBreaksTheChain() throws IOException {
List<HeatPumpSample> samples = List.of(
poll("2026-10-19T09:55:00Z", economy()), poll("2026-10-19T10:00:00Z", cwuComfort()),
poll("2026-10-19T10:40:00Z", economy()));
assertEquals(List.of(), monday(program("14", samples)).comfort());
}
@Test
void roomIsReadFromItsOwnTargets() throws IOException {
List<HeatPumpSample> samples = concat(
polls("2026-10-19T09:55", "2026-10-19T09:55", weekly(8.0, 18.0)),
polls("2026-10-19T10:00", "2026-10-19T10:55", weekly(8.0, 19.0)),
polls("2026-10-19T11:00", "2026-10-19T11:00", weekly(8.0, 18.0)));
ProgramView view = program("14", samples);
assertEquals(List.of(), monday(view).comfort());
assertEquals(List.of(new Interval("12:00", "13:00")), view.weekdays().get(0).room().comfort());
}
}
@@ -1,5 +1,8 @@
package home.backend.application; package home.backend.application;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.EnergyDetails; import home.backend.domain.EnergyDetails;
import home.backend.domain.EnergyDetails.Device; import home.backend.domain.EnergyDetails.Device;
import home.backend.domain.EnergyDetails.Group; import home.backend.domain.EnergyDetails.Group;
@@ -13,6 +16,8 @@ import home.backend.domain.HealthView;
import home.backend.domain.HeatPumpView; import home.backend.domain.HeatPumpView;
import home.backend.domain.HeatPumpView.State; import home.backend.domain.HeatPumpView.State;
import home.backend.domain.HistoryView; import home.backend.domain.HistoryView;
import home.backend.domain.Period;
import home.backend.domain.PiSensorView;
import home.backend.domain.HistoryView.Point; import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series; import home.backend.domain.Series;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -22,6 +27,7 @@ import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -66,14 +72,39 @@ class ServicesTest {
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state()); assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
} }
@Test
void heatPumpPeriodsComeFromTargetsAndSetpoints() throws IOException {
HeatPumpView view = heatPump(Map.of("program", 1.0, "target_cwu_temp", 43.0, "setpoint_cwu_economy", 8.0,
"setpoint_cwu_comfort", 43.0, "target_room_temp", 18.0, "setpoint_co_economy", 18.0,
"setpoint_co_comfort", 19.0));
assertEquals(Period.COMFORT, view.periods().cwu());
assertEquals(Period.ECONOMY, view.periods().room());
assertNull(view.periods().programText());
}
@Test
void heatPumpOtherProgramOverridesBothPeriodsAndIsNamed() throws IOException {
HeatPumpView view = heatPump(Map.of("program", 2.0, "target_cwu_temp", 43.0, "setpoint_cwu_economy", 8.0,
"setpoint_cwu_comfort", 43.0));
assertEquals(Period.OVERRIDE, view.periods().cwu());
assertEquals(Period.OVERRIDE, view.periods().room());
assertEquals("Party", view.periods().programText());
}
@Test
void heatPumpWithoutProgramHasUnknownPeriods() throws IOException {
assertEquals(Period.UNKNOWN, heatPump(Map.of("target_cwu_temp", 43.0)).periods().cwu());
}
@Test @Test
void heatPumpMapsOutputsAlarmAndTime() throws IOException { void heatPumpMapsOutputsAlarmAndTime() throws IOException {
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0, "output_heater", 100.0, HeatPumpView view = heatPump(Map.of("output_compressor", 66.0,
"output_cwu", 100.0, "output_co", 0.0, "alarm", 4.0, "setpoint_cwu_economy", 8.0)); "output_cwu", 100.0, "output_co", 0.0, "alarm", 4.0, "setpoint_cwu_economy", 8.0));
assertEquals(READ, view.time()); assertEquals(READ, view.time());
assertEquals(66, view.compressorPercent()); assertEquals(66, view.compressorPercent());
assertTrue(view.heaterOn());
assertTrue(view.alarm()); assertTrue(view.alarm());
assertTrue(view.pumps().cwu()); assertTrue(view.pumps().cwu());
assertFalse(view.pumps().co()); assertFalse(view.pumps().co());
@@ -170,6 +201,197 @@ class ServicesTest {
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current()); assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
} }
private static Map<String, Double> settings(double touEnabled) {
Map<String, Double> fields = new HashMap<>(Map.of("battery_capacity_ah", 280.0,
"tou_enabled", touEnabled, "grid_charge_enabled", 0.0));
double[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int n = 1; n <= 6; n++) {
fields.putAll(Map.of("prog" + n + "_start_hhmm", starts[n - 1], "prog" + n + "_power_w", 8000.0,
"prog" + n + "_soc_pct", 60.0, "prog" + n + "_grid_charge", n == 2 ? 1.0 : 0.0));
}
return fields;
}
private static BatteryView battery(DeviceSnapshot... devices) throws IOException {
return new BatteryService(gateway(devices), new FakeBattery(), CLOCK).current();
}
private static final class FakeBattery implements GatewayBattery {
static final List<String> SENT = new java.util.ArrayList<>();
final boolean enabled;
FakeBattery() {
this(true);
}
FakeBattery(boolean enabled) {
this.enabled = enabled;
}
@Override
public BatteryView.Reserve reserve() throws IOException {
if (!enabled) {
throw new IOException("gateway /battery/reserve answered 404");
}
return new BatteryView.Reserve(50, 30);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) {
SENT.add("schedule by " + requestedBy);
return new BatteryResult(BatteryResult.Status.APPLIED, "applied", Map.of());
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) {
SENT.add("reserve " + pct + " by " + requestedBy);
return new BatteryResult(BatteryResult.Status.APPLIED, "applied", Map.of());
}
}
@Test
void batteryShowsTheReserveAndIsEditableWhenTheGatewayAcceptsChanges() throws IOException {
BatteryView view = battery();
assertEquals(new BatteryView.Reserve(50, 30), view.reserve());
assertTrue(view.editable());
}
@Test
void batteryIsNotEditableWithoutGatewayCommandsButStillShowsTheLiveState() throws IOException {
BatteryView view = new BatteryService(gateway(device("deye", "home", "deye",
Map.of("battery_soc_pct", 80.0))), new FakeBattery(false), CLOCK).current();
assertNull(view.reserve());
assertFalse(view.editable());
assertEquals(80.0, view.socPct());
}
@Test
void batteryChangesAreHandedToTheGatewayWithTheUser() throws IOException {
var service = new BatteryService(gateway(), new FakeBattery(), CLOCK);
assertEquals(BatteryResult.Status.APPLIED, service.changeReserve(60, "anna").status());
assertEquals(BatteryResult.Status.APPLIED,
service.changeSchedule(new BatteryChange(true, null), "ola").status());
assertEquals(List.of("reserve 60 by anna", "schedule by ola"), FakeBattery.SENT);
}
private static DeviceSnapshot deyeSettings(Map<String, Double> fields, Instant time) {
return new DeviceSnapshot("deye-settings", true, time, null,
List.of(new Measurement("electricity", "deye_settings", fields, time)));
}
@Test
void batteryReadsLiveValuesAndComputesCapacityAtNominalVoltage() throws IOException {
BatteryView view = battery(device("deye", "home", "deye", Map.of("battery_soc_pct", 80.0,
"battery_power_w", 1500.0, "battery_charge_today_kwh", 2.5)),
deyeSettings(settings(0), READ));
assertFalse(view.stale());
assertEquals(80.0, view.socPct());
assertEquals(1500.0, view.powerW());
assertEquals(2.5, view.today().chargeKwh());
assertEquals(14.336, view.capacityKwh(), 1e-9);
}
@Test
void batteryScheduleRunsToTheNextStartAndWrapsAroundMidnight() throws IOException {
List<BatteryView.Slot> schedule = battery(deyeSettings(settings(1), READ)).schedule();
assertEquals(6, schedule.size());
assertEquals(new BatteryView.Slot("01:00", "05:00", 8000, 60, false), schedule.get(0));
assertEquals(new BatteryView.Slot("05:00", "09:00", 8000, 60, true), schedule.get(1));
assertEquals(new BatteryView.Slot("21:00", "01:00", 8000, 60, false), schedule.get(5));
}
@Test
void batteryScheduleIsEmptyWhenASlotStartIsMissing() throws IOException {
Map<String, Double> fields = settings(1);
fields.remove("prog3_start_hhmm");
assertTrue(battery(deyeSettings(fields, READ)).schedule().isEmpty());
}
@Test
void batteryModeFollowsTimeOfUse() throws IOException {
BatteryView backup = battery(deyeSettings(settings(0), READ));
BatteryView scheduled = battery(deyeSettings(settings(1), READ));
assertFalse(backup.mode().touEnabled());
assertEquals("Rezerwa na awarię: bateria nie rozładowuje się na co dzień", backup.mode().description());
assertTrue(scheduled.mode().touEnabled());
assertEquals("Harmonogram: bateria pracuje według przedziałów", scheduled.mode().description());
}
@Test
void batterySettingsAreStaleAfterThirtyMinutesButLiveValuesStayCurrent() throws IOException {
var live = device("deye", "home", "deye", Map.of("battery_soc_pct", 80.0));
BatteryView fresh = battery(live, deyeSettings(settings(0), NOW.minusSeconds(1800)));
BatteryView old = battery(live, deyeSettings(settings(0), NOW.minusSeconds(1801)));
assertFalse(fresh.settingsStale());
assertEquals(6, fresh.schedule().size());
assertTrue(old.settingsStale());
assertEquals(NOW.minusSeconds(1801), old.settingsTime());
assertTrue(old.schedule().isEmpty());
assertNull(old.mode());
assertNull(old.limits());
assertNull(old.capacityKwh());
assertEquals(80.0, old.socPct());
}
@Test
void staleDeyeShowsNoLiveBatteryValues() throws IOException {
var old = new Measurement("home", "deye", Map.of("battery_soc_pct", 80.0), NOW.minusSeconds(601));
BatteryView view = battery(new DeviceSnapshot("deye", true, old.time(), null, List.of(old)),
deyeSettings(settings(0), READ));
assertTrue(view.stale());
assertNull(view.socPct());
assertNull(view.powerW());
assertFalse(view.settingsStale());
}
@Test
void batteryWithoutAnyReadingIsStaleTwice() throws IOException {
BatteryView view = battery();
assertTrue(view.stale());
assertTrue(view.settingsStale());
assertNull(view.time());
assertNull(view.settingsTime());
}
private static PiSensorView piSensor(DeviceSnapshot... devices) throws IOException {
return new PiSensorService(gateway(devices), CLOCK).current();
}
@Test
void piSensorReadsTemperatureHumidityAndPressure() throws IOException {
PiSensorView view = piSensor(device("bme280", "measurement", "MEASUREMENT",
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8)));
assertEquals(READ, view.time());
assertFalse(view.stale());
assertEquals(20.17, view.temperature());
assertEquals(45.82, view.humidity());
assertEquals(985.8, view.pressure());
}
@Test
void stalePiSensorShowsNoValues() throws IOException {
var old = new Measurement("measurement", "MEASUREMENT", Map.of("temperature", 20.0), NOW.minusSeconds(601));
PiSensorView view = piSensor(new DeviceSnapshot("bme280", true, old.time(), null, List.of(old)));
assertTrue(view.stale());
assertNull(view.temperature());
assertTrue(piSensor().stale());
assertNull(piSensor().time());
}
private static EnergyView energy(DeviceSnapshot... devices) throws IOException { private static EnergyView energy(DeviceSnapshot... devices) throws IOException {
return new EnergyService(gateway(devices), CLOCK).current(); return new EnergyService(gateway(devices), CLOCK).current();
} }
@@ -206,7 +428,8 @@ class ServicesTest {
assertEquals(2000.0, view.pvPowerW()); assertEquals(2000.0, view.pvPowerW());
assertEquals(4.4, view.today().pvKwh()); assertEquals(4.4, view.today().pvKwh());
assertNull(view.gridPowerW()); assertNull(view.gridPowerW());
assertNull(view.today().loadKwh()); assertNull(view.housePowerW());
assertNull(view.today().houseKwh());
} }
@Test @Test
@@ -219,6 +442,82 @@ class ServicesTest {
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources()); assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
} }
private static Map<String, Double> deye(double pv1, double pv2, double battery, double grid) {
return Map.of("pv1_power_w", pv1, "pv2_power_w", pv2, "battery_power_w", battery, "grid_power_w", grid,
"load_power_w", 0.0);
}
private static EnergyView house(Map<String, Double> deye, double froniusW, double meterW) throws IOException {
DeviceSnapshot meter = device("house-meter", "electricity", "PRAD2",
Map.of("total_power_active", meterW));
return energy(device("deye", "home", "deye", deye),
device("fronius", "home", "fronius", Map.of("pv_power_w", froniusW)), meter);
}
@Test
void housePowerBalancesPvBatteryAndMeterWhileExporting() throws IOException {
EnergyView view = house(deye(2638.0, 3000.0, -15.0, -5768.0), 444.0, -5762.0);
assertEquals(305.0, view.housePowerW());
assertEquals(0.0, view.backupOutputW());
}
@Test
void housePowerAtNightIsImportPlusBatteryDischarge() throws IOException {
EnergyView view = house(deye(0.0, 0.0, 800.0, 1000.0), 0.0, 1000.0);
assertEquals(1800.0, view.housePowerW());
}
@Test
void housePowerIsNeverNegative() throws IOException {
assertEquals(0.0, house(deye(0.0, 0.0, -500.0, 100.0), 0.0, 100.0).housePowerW());
}
@Test
void housePowerFallsBackToDeyeGridWhenTheMeterIsStale() throws IOException {
Instant old = NOW.minusSeconds(3600);
var meter = new DeviceSnapshot("house-meter", true, old, null, List.of(new Measurement("electricity",
"PRAD2", Map.of("total_power_active", 9999.0), old)));
EnergyView view = energy(device("deye", "home", "deye", deye(1000.0, 0.0, 0.0, -600.0)),
device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0)), meter);
assertEquals(600.0, view.housePowerW());
}
@Test
void housePowerIsNullWhenASourceIsMissing() throws IOException {
Map<String, Double> deye = deye(1000.0, 0.0, 0.0, -600.0);
assertNull(energy(device("deye", "home", "deye", deye)).housePowerW());
assertNull(energy(device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0))).housePowerW());
var noGrid = Map.of("pv1_power_w", 1.0, "pv2_power_w", 1.0, "battery_power_w", 1.0);
assertNull(energy(device("deye", "home", "deye", noGrid),
device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0))).housePowerW());
}
@Test
void houseTodayIsPvPlusNetGridPlusNetBattery() throws IOException {
var deye = Map.of("pv_today_kwh", 29.9, "grid_bought_today_kwh", 4.4, "grid_sold_today_kwh", 16.8,
"battery_discharge_today_kwh", 5.0, "battery_charge_today_kwh", 2.1);
EnergyView view = energy(device("deye", "home", "deye", deye),
device("fronius", "home", "fronius", Map.of("energy_day_wh", 4400.0)));
assertEquals(24.8, view.today().houseKwh());
}
@Test
void houseTodayIsNullWhenACounterIsMissing() throws IOException {
var deye = Map.of("pv_today_kwh", 29.9, "grid_bought_today_kwh", 4.4, "grid_sold_today_kwh", 16.8,
"battery_discharge_today_kwh", 5.0);
EnergyView view = energy(device("deye", "home", "deye", deye),
device("fronius", "home", "fronius", Map.of("energy_day_wh", 4400.0)));
assertNull(view.today().houseKwh());
}
private static EnergyDetails details(DeviceSnapshot... devices) throws IOException { private static EnergyDetails details(DeviceSnapshot... devices) throws IOException {
return new EnergyDetailsService(gateway(devices), CLOCK).current(); return new EnergyDetailsService(gateway(devices), CLOCK).current();
} }
@@ -310,8 +609,9 @@ class ServicesTest {
assertNull(view.gridPowerW()); assertNull(view.gridPowerW());
assertNull(view.batteryPowerW()); assertNull(view.batteryPowerW());
assertNull(view.batterySocPct()); assertNull(view.batterySocPct());
assertNull(view.loadPowerW()); assertNull(view.housePowerW());
assertNull(view.today().loadKwh()); assertNull(view.backupOutputW());
assertNull(view.today().houseKwh());
} }
@Test @Test
@@ -21,6 +21,7 @@ import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -126,25 +127,88 @@ class SetpointServiceTest {
@Test @Test
void boostPresetSendsOneTimedCommand() throws Exception { void boostPresetSendsOneTimedCommand() throws Exception {
var result = service.applyPreset("cwu-now", null, "anna").orElseThrow(); var result = service.applyPreset("cwu-now", null, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.APPLIED, result.status()); assertEquals(PresetResult.Status.APPLIED, result.status());
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent); assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
} }
@Test
void chosenHoursReplaceTheTimedPresetsOwn() throws Exception {
service.applyPreset("cwu-now", null, 4, Map.of("cwu_economy", 47), "anna");
assertEquals(List.of(new Command("cwu_economy", 47, Duration.ofHours(4), "anna")), sent);
}
@Test
void hoursOutsideOneToFourAreRefusedBeforeAnythingIsSent() {
for (int hours : List.of(0, 5, -1, 72)) {
assertThrows(SetpointService.InvalidHoursException.class,
() -> service.applyPreset("cwu-now", null, hours, Map.of(), "anna"));
}
assertTrue(sent.isEmpty());
}
@Test
void aHeldPresetHasNoHoursToChoose() {
assertThrows(SetpointService.InvalidHoursException.class,
() -> service.applyPreset("away", RETURN, 2, Map.of(), "anna"));
assertTrue(sent.isEmpty());
}
@Test @Test
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception { void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
var result = service.applyPreset("away", RETURN, "anna").orElseThrow(); var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(2, result.results().size()); assertEquals(2, result.results().size());
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"), assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
new Command("co_comfort", 15, null, RETURN, "anna")), sent); new Command("co_comfort", 15, null, RETURN, "anna")), sent);
} }
@Test
void ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
service.applyPreset("away", RETURN, null, Map.of("co_comfort", 16), "anna");
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
}
@Test
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
service.applyPreset("cwu-now", null, null, Map.of("cwu_economy", 40), "anna");
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
}
@Test
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
assertThrows(IllegalArgumentException.class,
() -> service.applyPreset("away", RETURN, null, Map.of("cwu_economy", 6), "anna"));
assertTrue(sent.isEmpty());
}
@Test
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
for (int value : List.of(4, 26, 0)) {
assertThrows(IllegalArgumentException.class,
() -> service.applyPreset("away", RETURN, null, Map.of("co_comfort", value), "anna"));
}
assertTrue(sent.isEmpty());
}
@Test
void aHeldValueThatDoesNotLowerTheCurrentOneWritesNothing() throws Exception {
var result = service.applyPreset("away", RETURN, null, Map.of("co_comfort", 19), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
assertTrue(sent.isEmpty());
}
@Test @Test
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() { void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, "anna")); assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, null, Map.of(), "anna"));
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, "anna")); assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, null, Map.of(), "anna"));
assertTrue(sent.isEmpty()); assertTrue(sent.isEmpty());
} }
@@ -153,7 +217,7 @@ class SetpointServiceTest {
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null), var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
new Change("co_comfort", 19, null))); new Change("co_comfort", 19, null)));
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, "anna").orElseThrow(); var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status()); assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result)); assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
@@ -163,7 +227,7 @@ class SetpointServiceTest {
@Test @Test
void unknownPresetIsEmpty() throws Exception { void unknownPresetIsEmpty() throws Exception {
assertTrue(service.applyPreset("nope", null, "anna").isEmpty()); assertTrue(service.applyPreset("nope", null, null, Map.of(), "anna").isEmpty());
assertTrue(sent.isEmpty()); assertTrue(sent.isEmpty());
} }
@@ -172,7 +236,7 @@ class SetpointServiceTest {
var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null), var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null),
new Change("cwu_economy", 60, 2.0))); new Change("cwu_economy", 60, 2.0)));
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, "anna").orElseThrow(); var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status()); assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result)); assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
@@ -184,7 +248,7 @@ class SetpointServiceTest {
void presetWithUnknownSetpointWritesNothing() throws Exception { void presetWithUnknownSetpointWritesNothing() throws Exception {
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null))); var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, "anna").orElseThrow(); var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status()); assertEquals(PresetResult.Status.REJECTED, result.status());
assertTrue(sent.isEmpty()); assertTrue(sent.isEmpty());
@@ -198,7 +262,7 @@ class SetpointServiceTest {
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception { void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
script.add(Status.FAILED); script.add(Status.FAILED);
var result = service.applyPreset("away", RETURN, "anna").orElseThrow(); var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status()); assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result)); assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -209,7 +273,7 @@ class SetpointServiceTest {
void secondChangeRejectedLeavesThePresetPartial() throws Exception { void secondChangeRejectedLeavesThePresetPartial() throws Exception {
script.addAll(List.of(Status.APPLIED, Status.REJECTED)); script.addAll(List.of(Status.APPLIED, Status.REJECTED));
var result = service.applyPreset("away", RETURN, "anna").orElseThrow(); var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status()); assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result)); assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
@@ -219,7 +283,7 @@ class SetpointServiceTest {
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception { void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
script.add(Status.REJECTED); script.add(Status.REJECTED);
var result = service.applyPreset("away", RETURN, "anna").orElseThrow(); var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status()); assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result)); assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -229,7 +293,7 @@ class SetpointServiceTest {
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception { void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped"))); script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
var result = service.applyPreset("away", RETURN, "anna").orElseThrow(); var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status()); assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result)); assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
@@ -239,7 +303,7 @@ class SetpointServiceTest {
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception { void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
script.add(new IOException("tunnel dropped")); script.add(new IOException("tunnel dropped"));
var result = service.applyPreset("away", RETURN, "anna").orElseThrow(); var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status()); assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result)); assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -251,7 +315,7 @@ class SetpointServiceTest {
assertThrows(IOException.class, service::current); assertThrows(IOException.class, service::current);
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna")); assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, "anna")); assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, null, Map.of(), "anna"));
assertTrue(sent.isEmpty()); assertTrue(sent.isEmpty());
} }
} }
@@ -0,0 +1,213 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.Periods;
import home.backend.domain.StatsView;
import home.backend.domain.StatsView.Day;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class StatsServiceTest {
/** Monday 2026-10-26 11:00 in Warsaw; the week before it holds the change to winter time. */
private static final Instant NOW = Instant.parse("2026-10-26T10:00:00Z");
private Instant askedFrom;
private Instant askedTo;
private StatsView stats(String range, Instant now, HeatPumpSample... samples) throws IOException {
HeatPumpSamples repository = (from, to) -> {
askedFrom = from;
askedTo = to;
return List.of(samples);
};
return new StatsService(repository, Clock.fixed(now, ZoneOffset.UTC)).stats(range);
}
private static HeatPumpSample compressor(String time, double stage) {
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, null, null, Periods.UNKNOWN);
}
private static Day day(StatsView view, String date) {
return view.days().stream().filter(d -> d.date().equals(LocalDate.parse(date))).findFirst().orElseThrow();
}
@Test
void asksForWholeLocalDaysUpToNow() throws IOException {
stats("7d", NOW);
assertEquals(Instant.parse("2026-10-19T22:00:00Z"), askedFrom);
assertEquals(NOW, askedTo);
}
@Test
void listsOneDayPerLocalDateOldestFirst() throws IOException {
StatsView view = stats("30d", NOW);
assertEquals(30, view.days().size());
assertEquals(LocalDate.parse("2026-09-27"), view.days().getFirst().date());
assertEquals(LocalDate.parse("2026-10-26"), view.days().getLast().date());
}
@Test
void noDataGivesEmptyDaysAndZeroTotals() throws IOException {
StatsView view = stats("7d", NOW);
assertEquals(7, view.days().size());
for (Day d : view.days()) {
assertNull(d.compressorHours());
assertNull(d.compressorStarts());
assertNull(d.avgOutdoorTemp());
}
assertEquals(0.0, view.totals().compressorHours());
assertEquals(0, view.totals().compressorStarts());
assertNull(view.totals().avgOutdoorTemp());
assertNull(view.totals().minCwuTemp());
}
@Test
void aGapIsCappedAtTenMinutesPerPoll() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-23T10:00:00Z", 100),
compressor("2026-10-23T10:05:00Z", 100),
compressor("2026-10-23T11:00:00Z", 0));
assertEquals(0.25, day(view, "2026-10-23").compressorHours());
}
@Test
void theLastPollHoldsUntilNowAtMostTenMinutes() throws IOException {
StatsView recent = stats("7d", NOW, compressor("2026-10-26T09:58:00Z", 50));
StatsView old = stats("7d", NOW, compressor("2026-10-26T08:00:00Z", 50));
assertEquals(0.03, day(recent, "2026-10-26").compressorHours());
assertEquals(0.17, day(old, "2026-10-26").compressorHours());
}
@Test
void countsOnlyTransitionsFromOffToOn() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-23T10:00:00Z", 100),
compressor("2026-10-23T10:05:00Z", 100),
compressor("2026-10-23T10:10:00Z", 0),
compressor("2026-10-23T10:15:00Z", 0),
compressor("2026-10-23T10:20:00Z", 50),
compressor("2026-10-23T10:25:00Z", 100),
compressor("2026-10-23T10:30:00Z", 0),
compressor("2026-10-23T10:35:00Z", 100));
assertEquals(2, day(view, "2026-10-23").compressorStarts());
assertEquals(2, view.totals().compressorStarts());
}
@Test
void aStartAtMidnightBelongsToTheNewDay() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-24T21:55:00Z", 0),
compressor("2026-10-24T22:00:00Z", 100));
assertEquals(0, day(view, "2026-10-24").compressorStarts());
assertEquals(1, day(view, "2026-10-25").compressorStarts());
}
@Test
void splitsAStretchAtLocalMidnightInSummerTime() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-24T21:58:00Z", 100),
compressor("2026-10-24T22:03:00Z", 0));
assertEquals(0.03, day(view, "2026-10-24").compressorHours());
assertEquals(0.05, day(view, "2026-10-25").compressorHours());
}
@Test
void splitsAStretchAtLocalMidnightInWinterTime() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-25T22:58:00Z", 100),
compressor("2026-10-25T23:03:00Z", 0));
assertEquals(0.03, day(view, "2026-10-25").compressorHours());
assertEquals(0.05, day(view, "2026-10-26").compressorHours());
}
@Test
void theDayOfTheClockChangeLastsTwentyFiveHours() throws IOException {
List<HeatPumpSample> samples = new ArrayList<>();
Instant at = Instant.parse("2026-10-24T22:00:00Z");
while (at.isBefore(Instant.parse("2026-10-25T23:00:00Z"))) {
samples.add(compressor(at.toString(), 100));
at = at.plusSeconds(300);
}
StatsView view = stats("7d", NOW, samples.toArray(HeatPumpSample[]::new));
assertEquals(25.0, day(view, "2026-10-25").compressorHours());
assertNull(day(view, "2026-10-26").compressorHours());
}
@Test
void sumsEachOutputSeparately() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 100.0, 0.0, 10.0, 40.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 12.0, 38.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, null, null, Periods.UNKNOWN));
Day d = day(view, "2026-10-23");
assertEquals(0.17, d.compressorHours());
assertEquals(0.08, d.cwuHours());
assertEquals(0.08, d.coHours());
assertEquals(11.0, d.avgOutdoorTemp());
assertEquals(38.0, d.minCwuTemp());
assertEquals(40.0, d.maxCwuTemp());
}
@Test
void totalsCoverTheWholeRangeWithTheTemperatureOfAllPolls() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-22T10:00:00Z"), 100.0, 0.0, 0.0, 10.0, 30.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 10.0, 31.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 4.0, 45.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 4.0, 44.0, Periods.UNKNOWN));
assertEquals(0.17, view.totals().compressorHours());
assertEquals(7.0, view.totals().avgOutdoorTemp());
assertEquals(30.0, view.totals().minCwuTemp());
assertEquals(45.0, view.totals().maxCwuTemp());
}
@Test
void aMissingFieldCountsAsOffAndUnknownStateIsNoStart() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), null, null, null, null, null, Periods.UNKNOWN),
compressor("2026-10-23T10:05:00Z", 100));
assertEquals(0, day(view, "2026-10-23").compressorStarts());
assertNull(day(view, "2026-10-23").avgOutdoorTemp());
}
@Test
void rejectsAnUnknownRange() {
assertThrows(IllegalArgumentException.class, () -> stats("24h", NOW));
assertThrows(IllegalArgumentException.class, () -> stats(null, NOW));
}
@Test
void propagatesRepositoryFailure() {
StatsService service = new StatsService((from, to) -> {
throw new IOException("influx down");
}, Clock.fixed(NOW, ZoneOffset.UTC));
assertThrows(IOException.class, () -> service.stats("7d"));
}
}
@@ -0,0 +1,274 @@
package home.backend.application;
import home.backend.domain.Forecast;
import home.backend.domain.Forecast.DailyForecast;
import home.backend.domain.WeatherView;
import home.backend.domain.WeatherView.Warning;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WeatherServiceTest {
private static final Instant START = Instant.parse("2026-10-24T10:00:00Z");
private static final LocalDate TODAY = LocalDate.parse("2026-10-24");
private Instant now = START;
private final Clock clock = new Clock() {
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return Clock.fixed(now, zone);
}
@Override
public Instant instant() {
return now;
}
};
private final AtomicInteger forecastCalls = new AtomicInteger();
private final AtomicInteger warningCalls = new AtomicInteger();
private boolean forecastDown;
private boolean warningsDown;
private boolean influxDown;
private int measuredDays = 5;
private LocalDate today = TODAY;
private List<Warning> warned = List.of(warning(START.plus(Duration.ofHours(8))));
private final WeatherService service = new WeatherService(
() -> {
forecastCalls.incrementAndGet();
if (forecastDown) {
throw new IOException("open-meteo down");
}
return forecast();
},
() -> {
warningCalls.incrementAndGet();
if (warningsDown) {
throw new IOException("imgw down");
}
return warned;
},
(from, to) -> {
if (influxDown) {
throw new IOException("influx down");
}
assertEquals(today.minusDays(14), from);
assertEquals(today, to);
Map<LocalDate, Double> measured = new HashMap<>();
for (int i = 1; i <= measuredDays; i++) {
measured.put(today.minusDays(i), 15.0);
}
return measured;
},
clock);
private Forecast forecast() {
Map<LocalDate, DailyForecast> days = new TreeMap<>();
for (int i = 1; i <= 14; i++) {
days.put(today.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0));
}
days.put(today, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0));
days.put(today.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null));
days.put(today.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0));
return new Forecast(8.5, days);
}
private static Warning warning(Instant until) {
return new Warning("Burza", 2, OffsetDateTime.ofInstant(START, ZoneOffset.UTC),
OffsetDateTime.ofInstant(until, ZoneOffset.UTC), "text");
}
@Test
void shapesThreeDaysAndEstimatesPvFromTheFactor() throws IOException {
WeatherView view = service.current();
assertEquals(START, view.time());
assertEquals(8.5, view.now().temperature());
assertEquals(3.0, view.pvFactor(), 1e-9);
assertEquals(TODAY, view.today().date());
assertEquals("Bezchmurnie", view.today().description());
assertEquals(12.34, view.today().tMax());
assertEquals(1.5, view.today().sunshineHours());
assertEquals(18.0, view.today().pvForecastKwh());
assertEquals(TODAY.plusDays(2), view.dayAfter().date());
assertEquals("Deszcz", view.tomorrow().description());
assertEquals(5.5, view.tomorrow().precipitationMm());
assertNull(view.tomorrow().pvForecastKwh());
assertEquals(3.0, view.dayAfter().pvForecastKwh());
assertEquals("Brak danych", view.dayAfter().description());
assertFalse(view.stale());
}
@Test
void usesTheWarsawDateNearMidnight() throws IOException {
now = Instant.parse("2026-10-23T22:30:00Z");
assertEquals(TODAY, service.current().today().date());
}
@Test
void aForecastFetchedBeforeLocalMidnightIsNotUsedAfterIt() throws IOException {
now = Instant.parse("2026-10-24T21:50:00Z");
service.current();
now = Instant.parse("2026-10-24T22:05:00Z");
today = TODAY.plusDays(1);
WeatherView view = service.current();
assertEquals(2, forecastCalls.get());
assertEquals(today, view.today().date());
assertEquals(today.plusDays(2), view.dayAfter().date());
}
@Test
void aFailedRefreshServesTheOlderWarningsWithoutAskingAgainFor60Seconds() throws IOException {
service.current();
warningsDown = true;
now = START.plus(Duration.ofMinutes(11));
service.current();
assertEquals(2, warningCalls.get());
now = now.plusSeconds(59);
assertTrue(service.current().stale());
assertEquals(2, warningCalls.get());
now = now.plusSeconds(1);
service.current();
assertEquals(3, warningCalls.get());
}
@Test
void withoutAnyForecastItFailsFastDuringTheBackOff() {
forecastDown = true;
assertThrows(IOException.class, service::current);
now = START.plusSeconds(30);
assertThrows(IOException.class, service::current);
assertEquals(1, forecastCalls.get());
}
@Test
void fewerThanFiveMeasuredDaysGiveNoEstimate() throws IOException {
measuredDays = 4;
WeatherView view = service.current();
assertNull(view.pvFactor());
assertNull(view.today().pvForecastKwh());
assertEquals(12.34, view.today().tMax());
}
@Test
void anUnreachableInfluxGivesNoEstimateButStillTheWeather() throws IOException {
influxDown = true;
WeatherView view = service.current();
assertNull(view.pvFactor());
assertEquals(0, view.today().weatherCode());
assertFalse(view.stale());
}
@Test
void keepsTheForecastFor30MinutesAndWarningsFor10() throws IOException {
service.current();
now = START.plus(Duration.ofMinutes(9));
service.current();
assertEquals(1, warningCalls.get());
now = START.plus(Duration.ofMinutes(10));
service.current();
assertEquals(2, warningCalls.get());
assertEquals(1, forecastCalls.get());
now = START.plus(Duration.ofMinutes(30));
service.current();
assertEquals(2, forecastCalls.get());
}
@Test
void servesTheOlderForecastMarkedStaleWhenTheRefreshFails() throws IOException {
service.current();
forecastDown = true;
now = START.plus(Duration.ofMinutes(31));
WeatherView view = service.current();
assertTrue(view.stale());
assertEquals(8.5, view.now().temperature());
assertEquals(3.0, view.pvFactor(), 1e-9);
}
@Test
void servesTheOlderWarningsMarkedStaleWhenTheRefreshFails() throws IOException {
service.current();
warningsDown = true;
now = START.plus(Duration.ofMinutes(11));
WeatherView view = service.current();
assertTrue(view.stale());
assertEquals(1, view.warnings().size());
}
@Test
void withoutAnyForecastItFails() {
forecastDown = true;
assertThrows(IOException.class, service::current);
}
@Test
void withoutAnyWarningsItStillAnswersMarkedStale() throws IOException {
warningsDown = true;
WeatherView view = service.current();
assertTrue(view.warnings().isEmpty());
assertTrue(view.stale());
assertEquals(12.34, view.today().tMax());
}
@Test
void dropsWarningsThatHaveEnded() throws IOException {
warned = List.of(warning(START.plus(Duration.ofMinutes(5))), warning(START.plus(Duration.ofHours(8))));
assertEquals(2, service.current().warnings().size());
now = START.plus(Duration.ofMinutes(6));
assertEquals(1, service.current().warnings().size());
}
@Test
void aForecastWithoutTodayFails() {
var short_ = new WeatherService(() -> new Forecast(null, Map.of()), List::of, (from, to) -> Map.of(), clock);
assertThrows(IOException.class, short_::current);
}
}
@@ -0,0 +1,78 @@
package home.backend.domain;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.Targets;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class PeriodsTest {
/** CWU economy 8 and comfort 43, room economy 18 and comfort 19. */
private static Periods of(Integer program, double cwuTarget, double roomTarget) {
return Periods.of(program, new Targets(roomTarget, 31.0, cwuTarget), new Setpoints(18, 19, 8, 43));
}
@Test
void targetEqualToAComfortSetpointIsComfort() {
Periods periods = of(1, 43.0, 19.0);
assertEquals(Period.COMFORT, periods.cwu());
assertEquals(Period.COMFORT, periods.room());
}
@Test
void targetEqualToAnEconomySetpointIsEconomy() {
Periods periods = of(1, 8.0, 18.0);
assertEquals(Period.ECONOMY, periods.cwu());
assertEquals(Period.ECONOMY, periods.room());
}
@Test
void targetBetweenTheSetpointsIsUnknown() {
assertEquals(Period.UNKNOWN, of(1, 25.0, 18.5).cwu());
assertEquals(Period.UNKNOWN, of(1, 25.0, 18.5).room());
}
@Test
void equalSetpointsAreUnknownBecauseTheTargetIsTheSameEitherWay() {
Periods periods = Periods.of(1, new Targets(18.0, 31.0, 43.0), new Setpoints(18, 19, 43, 43));
assertEquals(Period.UNKNOWN, periods.cwu());
assertEquals(Period.ECONOMY, periods.room());
}
@Test
void aProgramOtherThanWeeklyOverridesAndIsNamed() {
String[] names = {"Ferie", null, "Party", "Rozruch", "Stop", "Dezynfekcja"};
for (int program = 0; program < names.length; program++) {
if (program != 1) {
Periods periods = of(program, 43.0, 19.0);
assertEquals(Period.OVERRIDE, periods.cwu());
assertEquals(Period.OVERRIDE, periods.room());
assertEquals(names[program], periods.programText());
}
}
assertEquals("Program 9", of(9, 8.0, 18.0).programText());
}
@Test
void weeklyProgramHasNoText() {
assertNull(of(1, 8.0, 18.0).programText());
}
@Test
void missingValuesAreUnknown() {
assertEquals(Periods.UNKNOWN, Periods.of(null, new Targets(null, null, null), new Setpoints(null, null, null, null)));
assertEquals(Period.UNKNOWN, Periods.of(1, new Targets(18.0, null, null), new Setpoints(18, 19, 8, 43)).cwu());
assertEquals(Period.UNKNOWN, Periods.of(1, new Targets(18.0, null, 8.0), new Setpoints(null, null, 8, null)).cwu());
}
@Test
void periodSerialisesAsLowerCaseId() {
assertEquals("comfort", Period.COMFORT.id());
assertEquals("override", Period.OVERRIDE.id());
}
}
@@ -0,0 +1,84 @@
package home.backend.domain;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class WeatherDomainTest {
private static final LocalDate DAY = LocalDate.parse("2026-10-10");
@Test
void describesWmoCodesInPolish() {
assertEquals("Bezchmurnie", WeatherCodes.describe(0));
assertEquals("Pochmurno", WeatherCodes.describe(3));
assertEquals("Mgła", WeatherCodes.describe(45));
assertEquals("Mżawka", WeatherCodes.describe(53));
assertEquals("Deszcz", WeatherCodes.describe(63));
assertEquals("Śnieg", WeatherCodes.describe(73));
assertEquals("Przelotny deszcz", WeatherCodes.describe(81));
assertEquals("Burza", WeatherCodes.describe(95));
assertEquals("Burza z gradem", WeatherCodes.describe(99));
}
@Test
void anUnknownOrMissingCodeIsNoData() {
assertEquals("Brak danych", WeatherCodes.describe(42));
assertEquals("Brak danych", WeatherCodes.describe(null));
}
@Test
void calibratesAsTotalMeasuredOverTotalRadiation() {
var measured = new HashMap<LocalDate, Double>();
var radiation = new HashMap<LocalDate, Double>();
for (int i = 0; i < 5; i++) {
measured.put(DAY.plusDays(i), 10.0 + i);
radiation.put(DAY.plusDays(i), 5.0 + i);
}
// 60 kWh over 35 MJ/m², not the mean of the daily ratios.
assertEquals(60.0 / 35.0, PvCalibration.factor(measured, radiation), 1e-9);
}
@Test
void daysMissingEitherValueDoNotCount() {
Map<LocalDate, Double> measured = new HashMap<>();
Map<LocalDate, Double> radiation = new HashMap<>();
for (int i = 0; i < 5; i++) {
measured.put(DAY.plusDays(i), 10.0);
radiation.put(DAY.plusDays(i), 5.0);
}
measured.put(DAY.plusDays(9), 1000.0);
radiation.put(DAY.plusDays(8), 1000.0);
assertEquals(2.0, PvCalibration.factor(measured, radiation), 1e-9);
}
@Test
void fewerThanFiveDaysGiveNoFactor() {
Map<LocalDate, Double> measured = new HashMap<>();
Map<LocalDate, Double> radiation = new HashMap<>();
for (int i = 0; i < 4; i++) {
measured.put(DAY.plusDays(i), 10.0);
radiation.put(DAY.plusDays(i), 5.0);
}
assertNull(PvCalibration.factor(measured, radiation));
}
@Test
void noRadiationGivesNoFactor() {
Map<LocalDate, Double> days = new HashMap<>();
for (int i = 0; i < 5; i++) {
days.put(DAY.plusDays(i), 0.0);
}
assertNull(PvCalibration.factor(Map.of(), days));
assertNull(PvCalibration.factor(days, days));
}
}
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff. Show more